000x's picture
Upload 6 files
33d5d60 verified
Raw History Blame Contribute Delete
43.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Markroom Pro</title>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=Noto+Sans+Sinhala:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#e9ebe8;--pn:#f5f6f4;--pn2:#fff;--ln:#d0d4cf;--tx:#15171a;--mu:#666d74;--ac:#e5195f;--r:6px}
*{box-sizing:border-box}
[hidden]{display:none!important}
html,body{height:100%;margin:0}
body{font:13.5px/1.45 "Hanken Grotesk",system-ui,sans-serif;background:var(--bg);color:var(--tx);display:grid;grid-template-rows:54px minmax(0,1fr) 144px;overflow:hidden}
button,select,input,textarea{font:inherit;color:inherit}
button{cursor:pointer;border:1px solid var(--ln);background:var(--pn2);border-radius:var(--r);padding:6px 12px}
button:hover{border-color:var(--tx)}
button.go{background:var(--ac);border-color:var(--ac);color:#fff;font-weight:700}
button.go:hover{filter:brightness(1.08)}
button:disabled{opacity:.4;cursor:default}
:focus-visible{outline:2px solid var(--ac);outline-offset:2px}
header{display:flex;align-items:center;gap:8px;padding:0 16px;background:var(--pn);border-bottom:1px solid var(--ln)}
.logo{display:flex;align-items:center;gap:9px;font:700 21px "Bricolage Grotesque",sans-serif;letter-spacing:-.03em;margin-right:14px}
.sp{flex:1}
.mu{color:var(--mu);font-size:12px}
main{display:grid;grid-template-columns:minmax(0,1fr) 300px;min-height:0}
#sw{position:relative;min-width:0;min-height:0;background:linear-gradient(#0000000b 1px,transparent 1px) 0 0/28px 28px,linear-gradient(90deg,#0000000b 1px,transparent 1px) 0 0/28px 28px,radial-gradient(ellipse at 50% 35%,#f2f4f4,#c6cbcc)}
#stage{position:absolute;inset:0;display:grid;place-items:center;padding:30px}
#cv{box-shadow:0 0 0 10px #fff,0 18px 46px #0006;touch-action:none;background:#000}
#empty{position:absolute;inset:0;display:grid;place-items:center;padding:24px;text-align:center}
#empty>div{max-width:560px;padding:34px 30px 30px;background:#fffc;backdrop-filter:blur(6px);border-radius:4px;position:relative}
#empty>div:before,#empty>div:after{content:"";position:absolute;width:22px;height:22px;border:2px solid var(--tx)}
#empty>div:before{left:0;top:0;border-right:0;border-bottom:0}
#empty>div:after{right:0;bottom:0;border-left:0;border-top:0}
#empty h2{font:700 38px/1.05 "Bricolage Grotesque",sans-serif;letter-spacing:-.035em;margin:0 0 10px}
#empty p{margin:0 0 22px;color:var(--mu)}
.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;text-align:left;margin-bottom:22px}
.steps div{background:var(--pn2);border:1px solid var(--ln);border-radius:var(--r);padding:12px}
.steps b{display:block;margin-bottom:3px;color:var(--ac)}
.steps span{color:var(--mu);font-size:12.5px}
#sw.drag #empty>div{outline:2px dashed var(--ac);outline-offset:12px}
aside{background:var(--pn);border-left:1px solid var(--ln);overflow:auto;padding:16px 16px 24px}
h3{font:600 15px "Bricolage Grotesque",sans-serif;letter-spacing:-.01em;margin:20px 0 9px;padding-top:14px;border-top:1px solid var(--ln)}
h3:first-child{margin-top:0;padding-top:0;border-top:0}
.row{display:flex;gap:6px;flex-wrap:wrap}
.lay{display:flex;align-items:center;gap:2px;border:1px solid var(--ln);border-radius:var(--r);padding:3px 3px 3px 10px;margin-bottom:5px;background:var(--pn2);cursor:pointer}
.lay.on{border-color:var(--ac);box-shadow:inset 3px 0 var(--ac)}
.lay b{flex:1;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lay.hid b{opacity:.4;text-decoration:line-through}
.lay button{border:0;background:none;padding:3px 7px;color:var(--mu)}
.lay button:hover{color:var(--tx)}
.f{display:grid;grid-template-columns:70px 1fr 44px;align-items:center;gap:6px;margin:7px 0}
.f output{text-align:right;color:var(--mu);font-size:12px}
input[type=range]{width:100%;accent-color:var(--ac)}
input[type=checkbox]{accent-color:var(--ac)}
.t{display:flex;align-items:center;gap:8px;margin:7px 0}
input[type=text],select,textarea{width:100%;font:inherit;color:inherit;resize:vertical;padding:6px 8px;border:1px solid var(--ln);border-radius:var(--r);background:var(--pn2)}
input[type=color]{width:40px;height:30px;padding:0;border:1px solid var(--ln);border-radius:var(--r);background:var(--pn2)}
.grid{display:grid;grid-template-columns:repeat(3,28px);gap:3px}
.grid button{height:28px;padding:0;position:relative}
.grid button:after{content:"";position:absolute;width:8px;height:8px;border-radius:2px;background:var(--mu)}
.grid button.on{border-color:var(--ac)}.grid button.on:after{background:var(--ac)}
.grid [data-a^=t]:after{top:4px}.grid [data-a^=b]:after{bottom:4px}.grid [data-a^=m]:after{top:9px}
.grid [data-a$=l]:after{left:4px}.grid [data-a$=r]:after{right:4px}.grid [data-a$=c]:after{left:9px}
.pos{display:flex;gap:14px;align-items:center}
.seg{display:flex;border:1px solid var(--ln);border-radius:var(--r);overflow:hidden;background:var(--pn2)}
.seg button{flex:1;border:0;border-radius:0;background:none;padding:6px 4px}
.seg button.on{background:var(--tx);color:#fff;font-weight:600}
.note{color:var(--mu);font-size:12px;margin:6px 0}
#strip{display:flex;gap:10px;padding:25px 16px;overflow-x:auto;
background:repeating-linear-gradient(90deg,#17181b 0 10px,#d9dcdf 10px 20px,#17181b 20px 30px) 0 8px/100% 8px no-repeat,repeating-linear-gradient(90deg,#17181b 0 10px,#d9dcdf 10px 20px,#17181b 20px 30px) 0 calc(100% - 8px)/100% 8px no-repeat,#17181b;background-attachment:local}
.th{flex:none;height:100%;border:0;padding:0;background:none;position:relative;outline-offset:3px;border-radius:2px;text-align:left}
.th.on{outline:2px solid var(--ac)}
.th canvas{display:block;height:calc(100% - 17px);width:auto;max-width:150px;border-radius:2px}
.th span{display:block;font-size:11px;color:#b9bdc2;max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:2px}
.th em{position:absolute;top:4px;right:4px;width:9px;height:9px;border-radius:50%;background:var(--ac);border:1.5px solid #fff}
#prog{position:fixed;left:0;top:0;height:3px;background:var(--ac);width:0;transition:width .2s;z-index:99}
#toast{position:fixed;bottom:160px;left:50%;transform:translateX(-50%);background:var(--tx);color:#fff;padding:9px 16px;border-radius:var(--r);opacity:0;pointer-events:none;transition:opacity .25s;z-index:99;max-width:90vw}
#toast.show{opacity:1}
#dlg{position:fixed;inset:0;background:#0008;display:none;place-items:center;z-index:50}
#dlg.show{display:grid}
#dlg>div{background:var(--pn);border:1px solid var(--ln);border-radius:12px;width:min(780px,94vw);max-height:86vh;display:flex;flex-direction:column}
#dh,#df{padding:12px 16px;display:flex;gap:8px;align-items:center}
#dh{border-bottom:1px solid var(--ln)}#df{border-top:1px solid var(--ln)}
#db{padding:14px 16px;overflow:auto;flex:1;min-height:200px}
.dg{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px}
.dt{border:2px solid var(--ln);border-radius:var(--r);background:var(--pn2);padding:0;overflow:hidden;text-align:left;cursor:pointer}
.dt.on{border-color:var(--ac)}
.dt img,.dt .ph{display:grid;place-items:center;width:100%;height:84px;object-fit:cover;background:var(--bg);font-size:26px}
.dt span{display:block;padding:4px 6px;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dlgs ol{margin:8px 0 12px;padding-left:18px;color:var(--mu)}.dlgs code{background:var(--bg);padding:1px 5px;border-radius:4px;color:var(--tx)}
@media(max-width:860px){body{overflow:auto;display:block}main{display:block}#sw{height:60vh}#strip{height:144px}}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div id="prog"></div>
<header>
<div class="logo"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#15171a" stroke-width="2" aria-hidden="true"><path d="M7 2v15h15M2 7h15v15"/><rect x="10" y="10" width="4.5" height="4.5" fill="#e5195f" stroke="none"/></svg>markroom</div>
<button id="bPhotos">Add photos</button>
<button id="bFolder">Add folder</button>
<button id="bDrive">Google Drive</button>
<span class="sp"></span>
<span class="mu" id="count"></span>
<button class="go" id="bExport" disabled>Export all</button>
</header>
<main>
<section id="sw">
<div id="stage"><canvas id="cv" hidden></canvas></div>
<div id="empty"><div>
<h2>Stamp a whole shoot in one go</h2>
<p>Drop photos anywhere on this page, or use the buttons above.</p>
<div class="steps">
<div><b>1. Add photos</b><span>From your computer, a folder, or Google Drive.</span></div>
<div><b>2. Add your mark</b><span>Your finished banner or logo (PNG or JPG), or a line of text. Stack as many as you like.</span></div>
<div><b>3. Place and export</b><span>Drag, resize and rotate it once. Every photo follows.</span></div>
</div>
<button class="go" id="bPhotos2">Choose photos</button>
</div></div>
</section>
<aside>
<h3>Watermarks</h3>
<div class="row" style="margin-bottom:8px"><button id="bLogo">Add image</button><button id="bText">Add text</button></div>
<div id="layers"></div>
<div id="ctl"></div>
<h3>My changes apply to</h3>
<div class="seg" id="scope"><button data-v="0" class="on">All photos</button><button data-v="1">This photo only</button></div>
<div class="row" style="margin-top:8px"><button id="bReset">Reset this photo</button></div>
<p class="note">Drag the mark to move it. Drag a square handle to resize, the round handle to rotate (hold Shift for 15° steps). Arrow keys nudge.</p>
<h3>Export</h3>
<div class="t"><label for="xf" style="width:60px">Format</label><select id="xf"><option value="jpg">JPEG</option><option value="png">PNG</option></select></div>
<div class="f"><label for="xq">Quality</label><input type="range" id="xq" min="50" max="100" value="92"><output id="xqo">92</output></div>
<div class="t"><label for="xs" style="width:60px">Size</label><select id="xs"><option value="0">Original</option><option value="3000">Long edge 3000 px</option><option value="2048">Long edge 2048 px</option><option value="1200">Long edge 1200 px</option></select></div>
<div class="t"><label for="xd" style="width:60px">Save as</label><select id="xd"><option value="zip">One ZIP file</option><option value="files">Separate downloads</option><option value="drive">Google Drive folder (no zip)</option></select></div>
<div id="xdrv" hidden><div class="t"><label for="xfn" style="width:60px">Folder</label><input type="text" id="xfn"></div><div class="row"><button id="bXAcc">Choose Google account</button></div><p class="note" id="xacc">Photos are saved into a new folder in the Google Drive of the account you choose. It can be a different account from the one you imported from.</p></div>
</aside>
</main>
<footer id="strip" aria-label="Photos"></footer>
<div id="toast" role="status"></div>
<div id="dlg"><div><div id="dh"></div><div id="db"></div><div id="df"></div></div></div>
<input type="file" id="fPhotos" accept="image/*" multiple hidden>
<input type="file" id="fFolder" webkitdirectory multiple hidden>
<input type="file" id="fLogo" accept="image/*" multiple hidden>
<script src="render.js"></script>
<script>
const $=s=>document.querySelector(s);
const S={items:[],layers:[],cur:-1,sel:null,only:false};
let uid=1,cvW=0,cvH=0,guides={v:0,h:0};
const cv=$('#cv'),cx=cv.getContext('2d');
const FONTS=['Hanken Grotesk','Georgia','Arial','Courier New','Impact','Noto Sans Sinhala','Iskoola Pota'];
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function toast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');clearTimeout(toast.t);toast.t=setTimeout(()=>t.classList.remove('show'),2800)}
const cur=()=>S.layers.find(l=>l.id===S.sel);
/* ---------- rendering ---------- */
const HND=[['tl',-1,-1],['tc',0,-1],['tr',1,-1],['mr',1,0],['br',1,1],['bc',0,1],['bl',-1,1],['ml',-1,0]];
function render(c,src,w,h,item,ui){
c.clearRect(0,0,w,h);c.drawImage(src,0,0,w,h);
let sb=null;
for(const L of S.layers){if(!L.vis)continue;const b=drawLayer(c,L,item,w,h);if(L.id===S.sel)sb=b}
if(!ui)return;
c.save();c.lineWidth=1;c.setLineDash([5,4]);c.strokeStyle='#e5195f';
if(guides.v){c.beginPath();c.moveTo(w/2,0);c.lineTo(w/2,h);c.stroke()}
if(guides.h){c.beginPath();c.moveTo(0,h/2);c.lineTo(w,h/2);c.stroke()}
c.restore();
if(!sb)return;
const{x,y,ww,hh,E}=sb;c.save();c.translate(x,y);c.rotate(E.rot*Math.PI/180);
const box=()=>c.strokeRect(-ww/2,-hh/2,ww,hh);
c.lineWidth=3;c.strokeStyle='#000a';box();c.lineWidth=1.5;c.strokeStyle='#e5195f';box();
c.beginPath();c.moveTo(0,-hh/2);c.lineTo(0,-hh/2-26);c.stroke();
c.fillStyle='#e5195f';c.strokeStyle='#ffffff';c.lineWidth=1.5;
for(const[,hx,hy]of HND){c.beginPath();c.rect(hx*ww/2-4.5,hy*hh/2-4.5,9,9);c.fill();c.stroke()}
c.beginPath();c.arc(0,-hh/2-28,6,0,7);c.fill();c.stroke();
c.restore();
if(drag){const t=`${Math.round(E.size*100)}% · ${Math.round(E.rot)}°`;c.font='600 12px system-ui';const tw=c.measureText(t).width+14;
const tx=clamp(x-tw/2,4,w-tw-4),ty=clamp(y+Math.hypot(ww,hh)/2+10,4,h-26);c.fillStyle='#e5195f';c.fillRect(tx,ty,tw,20);c.fillStyle='#ffffff';c.textBaseline='middle';c.textAlign='left';c.fillText(t,tx+7,ty+10)}
}
let raf=0,thT=0;
function draw(){cancelAnimationFrame(raf);raf=requestAnimationFrame(()=>{const it=S.items[S.cur];if(it)render(cx,it.bmp,cvW,cvH,it,true)})}
function drawThumbs(){clearTimeout(thT);thT=setTimeout(()=>S.items.forEach(it=>it.tc&&render(it.tc.getContext('2d'),it.th,it.tc.width,it.tc.height,it,false)),120)}
const redraw=()=>{draw();drawThumbs()};
function fit(){
const it=S.items[S.cur];if(!it)return;
const b=$('#stage').getBoundingClientRect(),s=Math.min((b.width-52)/it.w,(b.height-52)/it.h,1);
cvW=Math.max(1,Math.round(it.w*s));cvH=Math.max(1,Math.round(it.h*s));
const d=Math.min(devicePixelRatio||1,2);cv.width=cvW*d;cv.height=cvH*d;cv.style.width=cvW+'px';cv.style.height=cvH+'px';
cx.setTransform(d,0,0,d,0,0);draw()
}
addEventListener('resize',fit);
/* ---------- property edits (respect "all photos" / "this photo") ---------- */
function setProp(L,it,props){
if(S.only&&it){it.ov[L.id]=Object.assign(it.ov[L.id]||{},props)}
else{Object.assign(L,props);const pos='anchor'in props||'x'in props;
S.items.forEach(i=>{const o=i.ov[L.id];if(!o)return;for(const k in props)delete o[k];if(pos){delete o.anchor;delete o.x;delete o.y}if(!Object.keys(o).length)delete i.ov[L.id]})}
}
/* ---------- photos ---------- */
async function loadAny(f){
try{return await createImageBitmap(f)}catch(e1){}
const viaUrl=async u=>{const im=new Image();im.src=u;await im.decode();return im};
try{return await viaUrl(URL.createObjectURL(f))}catch(e2){}
const u=await new Promise((res,rej)=>{const r=new FileReader();r.onload=()=>res(r.result);r.onerror=()=>rej(new Error('file could not be read'));r.readAsDataURL(f)});
try{return await viaUrl(u)}catch(e3){throw new Error('this browser cannot decode it ('+(f.type||'unknown type')+', '+Math.round(f.size/1024)+' KB). Re-save it as a normal JPG or PNG.')}
}
async function addPhotos(files){
files=[...files].filter(f=>f.type.startsWith('image/')||/\.(jpe?g|png|webp|gif|bmp|avif)$/i.test(f.name));
if(!files.length){toast('No images found in that selection.');return}
let n=0;
for(const f of files){
try{
const bmp=await loadAny(f),k=Math.min(1,300/Math.max(bmp.width,bmp.height)),th=document.createElement('canvas');
th.width=Math.round(bmp.width*k);th.height=Math.round(bmp.height*k);th.getContext('2d').drawImage(bmp,0,0,th.width,th.height);
S.items.push({id:uid++,name:f.name,bmp,w:bmp.width,h:bmp.height,th,ov:{}});n++;if(S.cur<0)S.cur=0;
if(n%6===0){buildStrip();updateUI();fit()}
}catch(err){toast('Could not open '+f.name+': '+(err.message||err))}
}
buildStrip();updateUI();fit()
}
function buildStrip(){
const s=$('#strip');s.innerHTML='';
S.items.forEach((it,i)=>{
const b=document.createElement('button');b.className='th'+(i===S.cur?' on':'');
const tc=document.createElement('canvas');tc.width=it.th.width;tc.height=it.th.height;it.tc=tc;
render(tc.getContext('2d'),it.th,tc.width,tc.height,it,false);
const sp=document.createElement('span');sp.textContent=it.name;b.append(tc,sp);
if(Object.keys(it.ov).length){const e=document.createElement('em');e.title='Custom placement';b.append(e)}
b.onclick=()=>{S.cur=i;buildStrip();fit()};s.append(b)});
const on=s.querySelector('.on');if(on)on.scrollIntoView({inline:'nearest',block:'nearest'})
}
function updateUI(){
const has=S.items.length>0;$('#empty').hidden=has;cv.hidden=!has;$('#bExport').disabled=!has;
$('#count').textContent=has?`${S.items.length} photo${S.items.length>1?'s':''}`:''
}
$('#bPhotos').onclick=$('#bPhotos2').onclick=()=>$('#fPhotos').click();
$('#bFolder').onclick=()=>$('#fFolder').click();
$('#fPhotos').onchange=e=>{addPhotos(e.target.files);e.target.value=''};
$('#fFolder').onchange=e=>{addPhotos(e.target.files);e.target.value=''};
['dragenter','dragover'].forEach(t=>addEventListener(t,e=>{e.preventDefault();$('#sw').classList.add('drag')}));
['dragleave','drop'].forEach(t=>addEventListener(t,e=>{e.preventDefault();$('#sw').classList.remove('drag')}));
addEventListener('drop',e=>{if(e.dataTransfer.files.length)addPhotos(e.dataTransfer.files)});
/* ---------- layers + properties ---------- */
function newLayer(o){
const L={id:uid++,type:'image',name:'Watermark',size:.22,opacity:.7,rot:0,margin:.03,anchor:'br',x:.5,y:.5,tile:false,gap:.8,shadow:false,vis:true,text:'© Your Studio',font:FONTS[0],color:'#ffffff',weight:600,...o};
S.layers.push(L);S.sel=L.id;buildLayers();redraw()
}
$('#bText').onclick=()=>newLayer({type:'text',name:'Text',size:.28});
$('#bLogo').onclick=()=>$('#fLogo').click();
$('#fLogo').onchange=async e=>{
const list=[...e.target.files];e.target.value='';
for(const f of list){
try{addLogo(await loadAny(f),f.name.replace(/\.[^.]+$/,''))}catch(err){toast('Could not open '+f.name+': '+(err.message||err))}}
};
function buildLayers(){
const box=$('#layers');box.innerHTML='';
if(!S.layers.length)box.innerHTML='<p class="note">No watermark yet. A PNG with a transparent background looks best.</p>';
[...S.layers].reverse().forEach(L=>{
const d=document.createElement('div');d.className='lay'+(L.id===S.sel?' on':'')+(L.vis?'':' hid');
d.innerHTML='<b></b><button data-x="v" title="Show or hide">'+(L.vis?'Hide':'Show')+'</button><button data-x="u" title="Bring forward">↑</button><button data-x="d" title="Send back">↓</button><button data-x="r" title="Remove">✕</button>';
d.firstChild.textContent=L.type==='text'?'“'+L.text+'”':L.name;
d.onclick=e=>{const x=e.target.dataset.x,i=S.layers.indexOf(L);
if(x==='v')L.vis=!L.vis;
else if(x==='u'&&i<S.layers.length-1)[S.layers[i],S.layers[i+1]]=[S.layers[i+1],S.layers[i]];
else if(x==='d'&&i>0)[S.layers[i],S.layers[i-1]]=[S.layers[i-1],S.layers[i]];
else if(x==='r')removeLayer(L);
else if(!x)S.sel=L.id;
buildLayers();redraw()};
box.append(d)});
buildCtl()
}
function removeLayer(L){const i=S.layers.indexOf(L);S.layers.splice(i,1);S.items.forEach(it=>delete it.ov[L.id]);if(S.sel===L.id)S.sel=S.layers.length?S.layers[S.layers.length-1].id:null}
function sl(k,label,min,max,step,mul,suf,E){return`<div class="f"><label for="c_${k}">${label}</label><input type="range" id="c_${k}" data-k="${k}" data-m="${mul}" min="${min}" max="${max}" step="${step}" value="${E[k]*mul}"><output>${Math.round(E[k]*mul)}${suf}</output></div>`}
function buildCtl(){
const L=cur(),b=$('#ctl');if(!L){b.innerHTML='';return}
const it=S.items[S.cur],E=eff(L,it);
let h='<h3>Snap to position</h3><div class="pos"><div class="grid">'+['tl','tc','tr','ml','mc','mr','bl','bc','br'].map(a=>`<button data-a="${a}" aria-label="Snap ${a}" class="${E.anchor===a?'on':''}"></button>`).join('')+'</div><span class="note">Corner snaps adapt to each photo’s size and orientation.</span></div><h3>Appearance</h3>';
if(L.type==='text')h+=`<div class="t"><input type="text" id="c_text" aria-label="Text" value="${esc(L.text)}"><input type="color" id="c_color" aria-label="Colour" value="${L.color}"></div><div class="t"><select id="c_font" aria-label="Font">${FONTS.map(f=>`<option ${f===L.font?'selected':''}>${f}</option>`).join('')}</select></div>`;
if(L.type==='image')h+=`<div class="row"><button id="c_fit">Fit to photo width</button></div>`;if(L.type==='image')h+=`<div class="t"><label><input type="checkbox" id="c_cut" ${L.cut?'checked':''}> Remove white background</label></div>`+(L.cut?sl('tol','Tolerance',5,60,1,1,'',L):'');h+=sl('size','Size',1,100,.5,100,'%',E)+sl('rot','Rotate',-180,180,1,1,'°',E)+sl('opacity','Opacity',5,100,1,100,'%',L)+sl('margin','Edge gap',0,15,.5,100,'%',L);
h+=`<div class="t"><label><input type="checkbox" id="c_shadow" ${L.shadow?'checked':''}> Soft shadow</label></div><div class="t"><label><input type="checkbox" id="c_tile" ${L.tile?'checked':''}> Repeat across photo</label></div>`;
if(L.tile)h+=sl('gap','Spacing',0,300,5,100,'%',L);
b.innerHTML=h;
b.querySelectorAll('[data-a]').forEach(x=>x.onclick=()=>{setProp(L,S.items[S.cur],{anchor:x.dataset.a});buildCtl();buildStrip();redraw()});
b.querySelectorAll('input[type=range]').forEach(x=>x.oninput=()=>{
const k=x.dataset.k,v=x.value/x.dataset.m;
if(k==='size'||k==='rot')setProp(L,S.items[S.cur],{[k]:v});else{L[k]=v;if(k==='tol')cutApply(L)}
x.nextElementSibling.textContent=Math.round(x.value)+(k==='rot'?'°':'%');redraw()});
const t=$('#c_text');if(t)t.oninput=()=>{L.text=t.value;redraw();const n=$('#layers .on b');if(n)n.textContent='“'+L.text+'”'};
const c=$('#c_color');if(c)c.oninput=()=>{L.color=c.value;redraw()};
const f=$('#c_font');if(f)f.onchange=()=>{L.font=f.value;redraw()};
$('#c_shadow').onchange=e=>{L.shadow=e.target.checked;redraw()};
$('#c_tile').onchange=e=>{L.tile=e.target.checked;buildCtl();redraw()};
const ft=$('#c_fit');if(ft)ft.onclick=()=>{setProp(L,S.items[S.cur],{size:.98,anchor:'bc'});buildCtl();buildStrip();redraw()};
const cc=$('#c_cut');if(cc)cc.onchange=e=>{L.cut=e.target.checked;cutApply(L);buildCtl();redraw()}
}
$('#scope').onclick=e=>{const b=e.target.closest('button');if(!b)return;S.only=b.dataset.v==='1';[...$('#scope').children].forEach(x=>x.classList.toggle('on',x===b))};
$('#bReset').onclick=()=>{const it=S.items[S.cur];if(it){it.ov={};buildStrip();buildCtl();redraw()}};
/* ---------- mouse: move / resize / rotate ---------- */
let drag=null;
const pt=e=>{const r=cv.getBoundingClientRect();return{x:(e.clientX-r.left)*cvW/r.width,y:(e.clientY-r.top)*cvH/r.height}};
function geo(L,it){const E=eff(L,it),m=metrics(E,cx,cvW,cvH);return{E,m,...place(E,m,cvW,cvH)}}
function local(g,p){const a=-g.E.rot*Math.PI/180,dx=p.x-g.x,dy=p.y-g.y;return{x:dx*Math.cos(a)-dy*Math.sin(a),y:dx*Math.sin(a)+dy*Math.cos(a)}}
function handleAt(p,it){
const L=cur();if(!L||!L.vis)return null;const g=geo(L,it),q=local(g,p);
if(Math.hypot(q.x,q.y+g.m.hh/2+28)<=10)return'rot';
for(const[n,hx,hy]of HND)if(Math.abs(q.x-hx*g.m.ww/2)<=9&&Math.abs(q.y-hy*g.m.hh/2)<=9)return n;
return null
}
function bodyAt(p,it){
for(const L of[...S.layers].reverse()){if(!L.vis)continue;const g=geo(L,it),q=local(g,p);
if((L.tile&&L.id===S.sel)||(Math.abs(q.x)<=g.m.ww/2&&Math.abs(q.y)<=g.m.hh/2))return L}
return null
}
cv.onpointerdown=e=>{
const it=S.items[S.cur];if(!it)return;const p=pt(e);
const hn=handleAt(p,it);let L=cur();
if(!hn){const b=bodyAt(p,it);if(!b){S.sel=null;buildLayers();draw();return}L=b;if(S.sel!==b.id){S.sel=b.id;buildLayers()}}
const g=geo(L,it);
setProp(L,it,{anchor:null,x:g.x/cvW,y:g.y/cvH}); // freeze position so resize/rotate stay put
drag={L,mode:hn?(hn==='rot'?'rot':'scale'):'move',dx:p.x-g.x,dy:p.y-g.y,cx:g.x,cy:g.y,d0:Math.max(8,Math.hypot(p.x-g.x,p.y-g.y)),s0:g.E.size};
cv.setPointerCapture(e.pointerId);draw()
};
cv.onpointermove=e=>{
const it=S.items[S.cur];if(!it)return;const p=pt(e);
if(!drag){const h=handleAt(p,it);
cv.style.cursor=h==='rot'?'crosshair':h?({tl:'nwse-resize',br:'nwse-resize',tr:'nesw-resize',bl:'nesw-resize',tc:'ns-resize',bc:'ns-resize',ml:'ew-resize',mr:'ew-resize'})[h]:bodyAt(p,it)?'move':'default';return}
const L=drag.L;
if(drag.mode==='move'){let x=(p.x-drag.dx)/cvW,y=(p.y-drag.dy)/cvH;
guides.v=Math.abs(x-.5)<.012;guides.h=Math.abs(y-.5)<.012;if(guides.v)x=.5;if(guides.h)y=.5;
setProp(L,it,{anchor:null,x:clamp(x,0,1),y:clamp(y,0,1)})}
else if(drag.mode==='scale')setProp(L,it,{size:clamp(drag.s0*Math.hypot(p.x-drag.cx,p.y-drag.cy)/drag.d0,.01,3)});
else{let a=Math.atan2(p.y-drag.cy,p.x-drag.cx)*180/Math.PI+90;if(e.shiftKey)a=Math.round(a/15)*15;a=((a+540)%360)-180;setProp(L,it,{rot:a})}
draw()
};
cv.onpointerup=cv.onpointercancel=()=>{if(drag){drag=null;guides={v:0,h:0};buildCtl();buildStrip();redraw()}};
addEventListener('keydown',e=>{
if(/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)||$('#dlg').classList.contains('show'))return;
const L=cur(),it=S.items[S.cur];if(!L||!it)return;
if(e.key==='Delete'||e.key==='Backspace'){removeLayer(L);buildLayers();redraw();return}
const d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];if(!d)return;
e.preventDefault();const g=geo(L,it),s=(e.shiftKey?.01:.002);
setProp(L,it,{anchor:null,x:clamp(g.x/cvW+d[0]*s,0,1),y:clamp(g.y/cvH+d[1]*s,0,1)});buildStrip();redraw()
});
/* ---------- Google Drive ---------- */
const DR={tok:null,tab:'sharedWithMe',path:[],sel:new Map(),files:[]};
const DX={tok:null,email:''};
let CFG={};fetch('/api/config').then(r=>r.json()).then(j=>CFG=j).catch(()=>{});
const cid=()=>CFG.clientId||ls('mr_cid');
const SID=(()=>{let s=null;try{s=sessionStorage.getItem('mr_sid')}catch{}if(!s){s=[...crypto.getRandomValues(new Uint8Array(16))].map(b=>b.toString(16).padStart(2,'0')).join('');try{sessionStorage.setItem('mr_sid',s)}catch{}}return s})();
function loadGIS(){return new Promise((res,rej)=>{if(window.google&&google.accounts)return res();const s=document.createElement('script');s.src='https://accounts.google.com/gsi/client';s.onload=res;s.onerror=()=>rej(new Error('Could not reach Google. Check your internet.'));document.head.append(s)})}
async function readNDJSON(r,on){
const rd=r.body.getReader(),dec=new TextDecoder();let buf='';
for(;;){const{done,value}=await rd.read();if(done)break;buf+=dec.decode(value,{stream:true});let i;while((i=buf.indexOf('\n'))>=0){const line=buf.slice(0,i);buf=buf.slice(i+1);if(line.trim())on(JSON.parse(line))}}
if(buf.trim())on(JSON.parse(buf))
}
const ls=(k,v)=>{try{if(v===undefined)return localStorage.getItem(k);localStorage.setItem(k,v)}catch{return null}};
function dlg(show){$('#dlg').classList.toggle('show',show)}
$('#dlg').onclick=e=>{if(e.target.id==='dlg')dlg(false)};
$('#bDrive').onclick=()=>{dlg(true);cid()?driveConnect():driveSetup()};
function driveSetup(){
$('#dh').innerHTML='<b style="flex:1">Connect Google Drive</b><button id="dx">Close</button>';
$('#db').innerHTML=`<div class="dlgs"><p class="mu">One-time setup so this page can read your Drive photos (about 5 minutes).</p><ol>
<li>Open <code>console.cloud.google.com</code>, create a project, turn on <b>Google Drive API</b>.</li>
<li>OAuth consent screen: add your Google accounts as <b>Test users</b>.</li>
<li>Credentials → Create credentials → <b>OAuth client ID</b> → Web application.</li>
<li>Under Authorized JavaScript origins add <code>${esc(location.origin)}</code>.</li>
<li>Best: put the Client ID in the Space variable <code>GOOGLE_CLIENT_ID</code>. Or paste it here:</li></ol>
<div class="t"><input type="text" id="cid" placeholder="123456-abc.apps.googleusercontent.com" value="${esc(ls('mr_cid')||'')}"></div>
${location.protocol==='file:'?'<p class="note" style="color:var(--ac)">You opened this as a file. Open the hosted Space URL instead.</p>':''}</div>`;
$('#df').innerHTML='<span class="sp"></span><button class="go" id="dsave">Save and connect</button>';
$('#dx').onclick=()=>dlg(false);
$('#dsave').onclick=()=>{const v=$('#cid').value.trim();if(!v)return;ls('mr_cid',v);driveConnect()}
}
async function driveConnect(){
if(location.protocol==='file:'){driveSetup();return}
try{await loadGIS()}catch(e){toast(e.message);return}
DR.tc=google.accounts.oauth2.initTokenClient({client_id:cid(),scope:'https://www.googleapis.com/auth/drive.readonly',
callback:r=>{if(r.access_token){DR.tok=r.access_token;DR.path=[];driveList()}else driveSetup()}});
DR.tc.requestAccessToken()
}
async function driveList(){
$('#db').innerHTML='<p class="mu">Loading…</p>';
const parent=DR.path.length?DR.path[DR.path.length-1].id:null;
let q=(parent?`'${parent}' in parents`:DR.tab==='sharedWithMe'?'sharedWithMe=true':"'root' in parents")+" and trashed=false and (mimeType='application/vnd.google-apps.folder' or mimeType contains 'image/')";
try{
let files=[],tok='';
do{const r=await fetch('https://www.googleapis.com/drive/v3/files?pageSize=500&orderBy=folder,name&fields=nextPageToken,files(id,name,mimeType,thumbnailLink)&q='+encodeURIComponent(q)+(tok?'&pageToken='+tok:''),{headers:{Authorization:'Bearer '+DR.tok}});
if(!r.ok)throw new Error(r.status===401?'Session expired. Reconnect.':'Drive returned '+r.status);
const j=await r.json();files=files.concat(j.files);tok=j.nextPageToken||''}while(tok&&files.length<2000);
DR.files=files;driveView()
}catch(err){$('#db').innerHTML='<p style="color:var(--ac)">'+esc(err.message)+'</p>'}
}
function driveView(){
const imgs=DR.files.filter(f=>f.mimeType.startsWith('image/'));
$('#dh').innerHTML=`<div class="seg" style="width:240px"><button data-t="sharedWithMe" class="${DR.tab==='sharedWithMe'?'on':''}">Shared with me</button><button data-t="mine" class="${DR.tab==='mine'?'on':''}">My Drive</button></div><span class="mu" style="flex:1" id="crumb"></span><button id="dx">Close</button>`;
$('#dh').querySelectorAll('[data-t]').forEach(b=>b.onclick=()=>{DR.tab=b.dataset.t;DR.path=[];driveList()});
$('#crumb').textContent=DR.path.map(p=>p.name).join(' / ');
const g=document.createElement('div');g.className='dg';
if(DR.path.length){const b=document.createElement('button');b.className='dt';b.innerHTML='<div class="ph">↩</div><span>Back</span>';b.onclick=()=>{DR.path.pop();driveList()};g.append(b)}
DR.files.forEach(f=>{const b=document.createElement('button');const isF=f.mimeType.includes('folder');
b.className='dt'+(DR.sel.has(f.id)?' on':'');
b.innerHTML=(isF?'<div class="ph">📁</div>':f.thumbnailLink?`<img src="${esc(f.thumbnailLink)}" referrerpolicy="no-referrer" alt="">`:'<div class="ph">🖼</div>')+'<span>'+esc(f.name)+'</span>';
b.onclick=()=>{if(isF){DR.path.push({id:f.id,name:f.name});driveList()}else{DR.sel.has(f.id)?DR.sel.delete(f.id):DR.sel.set(f.id,f);b.classList.toggle('on');upd()}};g.append(b)});
$('#db').innerHTML='';if(!DR.files.length)$('#db').innerHTML='<p class="mu">Nothing here. Photos and folders will show up in this view.</p>';$('#db').append(g);
$('#df').innerHTML='<button id="dall">Select all photos here</button><span class="sp"></span><span class="mu" id="dn"></span><button class="go" id="dgo">Import</button>';
const upd=()=>{$('#dn').textContent=DR.sel.size+' selected';$('#dgo').disabled=!DR.sel.size};upd();
$('#dall').onclick=()=>{imgs.forEach(f=>DR.sel.set(f.id,f));driveView()};
$('#dx').onclick=()=>dlg(false);
$('#dgo').onclick=importFromDrive;
}
async function addServerItem(info){
const r=await fetch(`/api/preview/${SID}/${info.rid}`);if(!r.ok)throw new Error('preview '+r.status);
const bmp=await createImageBitmap(await r.blob());
const k=Math.min(1,300/Math.max(bmp.width,bmp.height)),th=document.createElement('canvas');
th.width=Math.round(bmp.width*k);th.height=Math.round(bmp.height*k);th.getContext('2d').drawImage(bmp,0,0,th.width,th.height);
S.items.push({id:uid++,name:info.name,bmp,w:info.w,h:info.h,th,ov:{},rid:info.rid});
if(S.cur<0)S.cur=0
}
async function importFromDrive(){
const list=[...DR.sel.values()];if(!list.length)return;
DR.sel.clear();dlg(false);
let done=0,ok=0,rt=0;const tasks=[];
const refresh=()=>{clearTimeout(rt);rt=setTimeout(()=>{buildStrip();updateUI();fit()},120)};
const bar=()=>{$('#prog').style.width=(done/list.length*100)+'%';$('#count').textContent=`Importing ${done} / ${list.length}`};
bar();
try{
const r=await fetch('/api/import',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+DR.tok},body:JSON.stringify({sid:SID,files:list.map(f=>({id:f.id,name:f.name}))})});
if(!r.ok)throw new Error(r.status===401?'Session expired. Click Google Drive and reconnect.':'Server error '+r.status);
await readNDJSON(r,m=>{
if(m.t==='item')tasks.push(addServerItem(m).then(()=>{ok++}).catch(e=>toast('Could not open '+m.name+': '+e.message)).finally(()=>{done++;bar();refresh()}));
else if(m.t==='err'){done++;bar();toast('Could not download '+m.name+': '+m.msg)}
});
await Promise.all(tasks)
}catch(e){toast(e.message)}
clearTimeout(rt);buildStrip();updateUI();fit();$('#prog').style.width='0';
toast(`Imported ${ok} photo${ok===1?'':'s'} from Drive.`)
}
/* ---------- export ---------- */
const crcT=(()=>{const t=new Uint32Array(256);for(let n=0;n<256;n++){let c=n;for(let k=0;k<8;k++)c=c&1?0xEDB88320^(c>>>1):c>>>1;t[n]=c>>>0}return t})();
const crc=d=>{let c=~0;for(let i=0;i<d.length;i++)c=crcT[(c^d[i])&255]^(c>>>8);return~c>>>0};
function zip(files){
const enc=new TextEncoder(),parts=[],cen=[];let off=0;
const d=new Date(),dt=((d.getFullYear()-1980)<<9)|((d.getMonth()+1)<<5)|d.getDate(),tm=(d.getHours()<<11)|(d.getMinutes()<<5)|(d.getSeconds()>>1);
for(const f of files){
const nm=enc.encode(f.name),c=crc(f.data),h=new DataView(new ArrayBuffer(30)),n=f.data.length;
h.setUint32(0,0x04034b50,true);h.setUint16(4,20,true);h.setUint16(6,0x0800,true);h.setUint16(10,tm,true);h.setUint16(12,dt,true);h.setUint32(14,c,true);h.setUint32(18,n,true);h.setUint32(22,n,true);h.setUint16(26,nm.length,true);
parts.push(h.buffer,nm,f.data);
const g=new DataView(new ArrayBuffer(46));
g.setUint32(0,0x02014b50,true);g.setUint16(4,20,true);g.setUint16(6,20,true);g.setUint16(8,0x0800,true);g.setUint16(12,tm,true);g.setUint16(14,dt,true);g.setUint32(16,c,true);g.setUint32(20,n,true);g.setUint32(24,n,true);g.setUint16(28,nm.length,true);g.setUint32(42,off,true);
cen.push(g.buffer,nm);off+=30+nm.length+n}
const cs=cen.reduce((a,b)=>a+b.byteLength,0),e=new DataView(new ArrayBuffer(22));
e.setUint32(0,0x06054b50,true);e.setUint16(8,files.length,true);e.setUint16(10,files.length,true);e.setUint32(12,cs,true);e.setUint32(16,off,true);
return new Blob([...parts,...cen,e.buffer],{type:'application/zip'})
}
function save(blob,name){const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),4000)}
$('#xq').oninput=e=>$('#xqo').textContent=e.target.value;
$('#xd').onchange=()=>{$('#xdrv').hidden=$('#xd').value!=='drive';if(!$('#xfn').value)$('#xfn').value='Markroom '+new Date().toISOString().slice(0,10)};
async function exportConnect(pick){
if(!cid()){driveSetup();dlg(true);throw new Error('Set up the Google Client ID first.')}
await loadGIS();
const tok=await new Promise((res,rej)=>{
const tc=google.accounts.oauth2.initTokenClient({client_id:cid(),scope:'https://www.googleapis.com/auth/drive.file email',prompt:pick?'select_account':'',
callback:r=>r.access_token?res(r.access_token):rej(new Error(r.error||'Google sign-in failed')),
error_callback:e=>rej(new Error(e.type==='popup_closed'?'Sign-in window was closed.':'Google sign-in failed'))});
tc.requestAccessToken()});
DX.tok=tok;DX.email='';
try{const u=await (await fetch('https://www.googleapis.com/oauth2/v3/userinfo',{headers:{Authorization:'Bearer '+tok}})).json();DX.email=u.email||''}catch{}
$('#xacc').textContent=DX.email?'Exporting to the Drive of '+DX.email+'. Use the button to switch account.':'Google account connected.';
return tok
}
$('#bXAcc').onclick=()=>exportConnect(true).catch(e=>toast(e.message));
async function gFolder(tok,name){
const r=await fetch('https://www.googleapis.com/drive/v3/files?fields=id',{method:'POST',headers:{Authorization:'Bearer '+tok,'Content-Type':'application/json'},body:JSON.stringify({name,mimeType:'application/vnd.google-apps.folder'})});
if(!r.ok)throw new Error(r.status===401?'Google session expired. Choose the account again.':'Could not create the folder ('+r.status+')');
return(await r.json()).id
}
async function gUpload(tok,folderId,name,blob){
const i=await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id',{method:'POST',headers:{Authorization:'Bearer '+tok,'Content-Type':'application/json; charset=UTF-8','X-Upload-Content-Type':blob.type},body:JSON.stringify({name,parents:[folderId]})});
if(!i.ok)throw new Error('Drive '+i.status);
const p=await fetch(i.headers.get('Location'),{method:'PUT',headers:{'Content-Type':blob.type},body:blob});
if(!p.ok)throw new Error('Drive upload '+p.status)
}
async function pushSettings(){
const keys=['id','type','size','opacity','rot','margin','anchor','x','y','tile','gap','shadow','vis','text','font','color','weight'];
const layers=S.layers.map(L=>{const o={};keys.forEach(k=>o[k]=L[k]);if(L.type==='image')o.png=L.cv.toDataURL('image/png');return o});
const r=await fetch(`/api/settings/${SID}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layers})});
if(!r.ok)throw new Error('Could not send watermark settings to the server');
}
async function serverRender(it,o){
const r=await fetch(`/api/render/${SID}/${it.rid}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ov:it.ov,...o})});
if(!r.ok)throw new Error(r.status===404?'Server forgot this photo (Space restarted). Import it again.':await r.text());
return r.blob()
}
async function localRender(it,max,mime,q){
const k=max?Math.min(1,max/Math.max(it.w,it.h)):1,w=Math.round(it.w*k),h=Math.round(it.h*k),c=document.createElement('canvas');
c.width=w;c.height=h;render(c.getContext('2d'),it.bmp,w,h,it,false);
return new Promise(r=>c.toBlob(r,mime,q))
}
$('#bExport').onclick=async()=>{
if(!S.layers.some(l=>l.vis)&&!confirm('No watermark is visible. Export the photos anyway?'))return;
const png=$('#xf').value==='png',q=$('#xq').value/100,max=+$('#xs').value,mode=$('#xd').value,btn=$('#bExport');
const mime=png?'image/png':'image/jpeg',ext=png?'.png':'.jpg',used=new Set(),N=S.items.length;btn.disabled=true;
const names=S.items.map(it=>{let nm=it.name.replace(/\.[^.]+$/,'')+'_wm'+ext;if(used.has(nm))nm=nm.replace(ext,'_'+it.id+ext);used.add(nm);return nm});
let done=0;const prog=()=>{btn.textContent=`Exporting ${done} / ${N}`;$('#prog').style.width=(done/N*100)+'%'};
try{
if(S.items.some(i=>i.rid))await pushSettings();
if(mode==='drive'){
const tok=DX.tok||await exportConnect(false),fname=$('#xfn').value.trim()||'Markroom export';
const folderId=await gFolder(tok,fname);let ok=0,fail=0;prog();
const srv=S.items.map((it,i)=>[it,i]).filter(([it])=>it.rid),jobs=[];
if(srv.length)jobs.push((async()=>{
const r=await fetch('/api/export-drive',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+tok},body:JSON.stringify({sid:SID,folderId,fmt:png?'png':'jpg',q,max,items:srv.map(([it,i])=>({rid:it.rid,name:names[i],ov:it.ov}))})});
if(!r.ok)throw new Error(await r.text());
await readNDJSON(r,m=>{if(m.t==='ok'){ok++;done++;prog()}else if(m.t==='err'){fail++;done++;prog();toast('Failed: '+m.name+' ('+m.msg+')')}})
})());
jobs.push((async()=>{for(let i=0;i<N;i++){const it=S.items[i];if(it.rid)continue;
try{await gUpload(tok,folderId,names[i],await localRender(it,max,mime,q));ok++}catch(e){fail++;toast('Failed: '+names[i]+' ('+e.message+')')}
done++;prog()}})());
await Promise.all(jobs);
dlg(true);$('#dh').innerHTML='<b style="flex:1">Exported to Google Drive</b><button id="dx">Close</button>';
$('#db').innerHTML=`<div class="dlgs"><p>${ok} photo${ok===1?'':'s'} saved in the folder <b>${esc(fname)}</b>${DX.email?' in '+esc(DX.email):''}.${fail?' '+fail+' failed.':''}</p><p><a href="https://drive.google.com/drive/folders/${folderId}" target="_blank" rel="noopener">Open folder in Google Drive</a></p></div>`;
$('#df').innerHTML='';$('#dx').onclick=()=>dlg(false)
}else{
const files=[];
for(let i=0;i<N;i++){const it=S.items[i];
const blob=it.rid?await serverRender(it,{fmt:png?'png':'jpg',q,max}):await localRender(it,max,mime,q);
files.push({name:names[i],data:new Uint8Array(await blob.arrayBuffer())});done++;prog()}
if(mode==='zip')save(zip(files),'watermarked.zip');
else for(const f of files){save(new Blob([f.data],{type:mime}),f.name);await new Promise(r=>setTimeout(r,250))}
toast(`Exported ${files.length} photo${files.length>1?'s':''}.`)
}
}catch(err){toast('Export stopped: '+err.message)}
btn.textContent='Export all';btn.disabled=false;setTimeout(()=>$('#prog').style.width='0',800)
};
/* ---------- white-background removal for logos (JPG has no transparency) ---------- */
function mkBase(el){
const W=el.naturalWidth||el.width,H=el.naturalHeight||el.height,k=Math.min(1,3000/Math.max(W,H)),c=document.createElement('canvas');
c.width=Math.max(1,Math.round(W*k));c.height=Math.max(1,Math.round(H*k));
c.getContext('2d').drawImage(el,0,0,c.width,c.height);return c
}
function autoCut(c){
const w=c.width,h=c.height,d=c.getContext('2d').getImageData(0,0,w,h).data;
for(let i=3;i<d.length;i+=4)if(d[i]<250)return false;
return[[0,0],[w-1,0],[0,h-1],[w-1,h-1]].every(([x,y])=>{const i=(y*w+x)*4;return Math.min(d[i],d[i+1],d[i+2])>=235})
}
function cutWhite(base,tol){
const w=base.width,h=base.height,src=base.getContext('2d').getImageData(0,0,w,h),d=new Uint8ClampedArray(src.data),thr=255-tol,lo=thr-70;
const seen=new Uint8Array(w*h),st=[],mn=i=>Math.min(d[i*4],d[i*4+1],d[i*4+2]);
const push=i=>{if(!seen[i]&&mn(i)>=thr){seen[i]=1;st.push(i)}};
for(let x=0;x<w;x++){push(x);push((h-1)*w+x)}for(let y=0;y<h;y++){push(y*w);push(y*w+w-1)}
while(st.length){const i=st.pop(),x=i%w,y=(i/w)|0;if(x>0)push(i-1);if(x<w-1)push(i+1);if(y>0)push(i-w);if(y<h-1)push(i+w)}
const a=new Uint8ClampedArray(w*h).fill(255);
for(let i=0;i<w*h;i++){
if(seen[i]){a[i]=0;continue}
const x=i%w,y=(i/w)|0;
if((x>0&&seen[i-1])||(x<w-1&&seen[i+1])||(y>0&&seen[i-w])||(y<h-1&&seen[i+w]))a[i]=Math.round(255*clamp((thr-mn(i))/(thr-lo),0,1))
}
for(let i=0;i<w*h;i++)d[i*4+3]=a[i];
const o=document.createElement('canvas');o.width=w;o.height=h;o.getContext('2d').putImageData(new ImageData(d,w,h),0,0);return o
}
function cutApply(o){o.cv=o.cut?cutWhite(o.base,o.tol):o.base}
function addLogo(el,name){
const base=mkBase(el);let cut=false;try{cut=autoCut(base)}catch(e){}
const o={name,base,cut,tol:30,opacity:1,cv:base};
if(base.width/base.height>6)Object.assign(o,{size:.96,anchor:'bc',margin:.01});
try{cutApply(o)}catch(e){o.cut=false;o.cv=base}
newLayer(o);
if(cut)toast('White background removed. You can turn this off in the watermark settings.')
}
buildLayers();updateUI();
</script>
</body>
</html>