000x commited on
Commit
cbaf8eb
·
verified ·
1 Parent(s): 0d0d792

Upload 4 files

Browse files
Files changed (4) hide show
  1. public/README.md +21 -0
  2. public/index.html +577 -0
  3. public/render.js +31 -0
  4. public/server.js +225 -0
public/README.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Markroom
3
+ emoji: 🖼️
4
+ colorFrom: pink
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Markroom
12
+
13
+ Batch watermark tool. Drive photos are downloaded and exported on the server.
14
+
15
+ ## Setup
16
+ 1. Create a Hugging Face Space (SDK: Docker) and upload these files (keep the `public/` folder).
17
+ 2. Google Cloud Console: enable Google Drive API, create an OAuth client ID (Web application),
18
+ add your Space URL (`https://<user>-<space>.hf.space`) under Authorized JavaScript origins,
19
+ and add your Google accounts as Test users on the consent screen.
20
+ 3. Space Settings -> Variables: add `GOOGLE_CLIENT_ID` = your client ID.
21
+ 4. Open the direct `*.hf.space` URL (not the huggingface.co page) so Google sign-in popups work.
public/index.html ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Markroom Pro</title>
7
+ <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">
8
+ <style>
9
+ :root{--bg:#e9ebe8;--pn:#f5f6f4;--pn2:#fff;--ln:#d0d4cf;--tx:#15171a;--mu:#666d74;--ac:#e5195f;--r:6px}
10
+ *{box-sizing:border-box}
11
+ [hidden]{display:none!important}
12
+ html,body{height:100%;margin:0}
13
+ 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}
14
+ button,select,input,textarea{font:inherit;color:inherit}
15
+ button{cursor:pointer;border:1px solid var(--ln);background:var(--pn2);border-radius:var(--r);padding:6px 12px}
16
+ button:hover{border-color:var(--tx)}
17
+ button.go{background:var(--ac);border-color:var(--ac);color:#fff;font-weight:700}
18
+ button.go:hover{filter:brightness(1.08)}
19
+ button:disabled{opacity:.4;cursor:default}
20
+ :focus-visible{outline:2px solid var(--ac);outline-offset:2px}
21
+ header{display:flex;align-items:center;gap:8px;padding:0 16px;background:var(--pn);border-bottom:1px solid var(--ln)}
22
+ .logo{display:flex;align-items:center;gap:9px;font:700 21px "Bricolage Grotesque",sans-serif;letter-spacing:-.03em;margin-right:14px}
23
+ .sp{flex:1}
24
+ .mu{color:var(--mu);font-size:12px}
25
+ main{display:grid;grid-template-columns:minmax(0,1fr) 300px;min-height:0}
26
+ #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)}
27
+ #stage{position:absolute;inset:0;display:grid;place-items:center;padding:30px}
28
+ #cv{box-shadow:0 0 0 10px #fff,0 18px 46px #0006;touch-action:none;background:#000}
29
+ #empty{position:absolute;inset:0;display:grid;place-items:center;padding:24px;text-align:center}
30
+ #empty>div{max-width:560px;padding:34px 30px 30px;background:#fffc;backdrop-filter:blur(6px);border-radius:4px;position:relative}
31
+ #empty>div:before,#empty>div:after{content:"";position:absolute;width:22px;height:22px;border:2px solid var(--tx)}
32
+ #empty>div:before{left:0;top:0;border-right:0;border-bottom:0}
33
+ #empty>div:after{right:0;bottom:0;border-left:0;border-top:0}
34
+ #empty h2{font:700 38px/1.05 "Bricolage Grotesque",sans-serif;letter-spacing:-.035em;margin:0 0 10px}
35
+ #empty p{margin:0 0 22px;color:var(--mu)}
36
+ .steps{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;text-align:left;margin-bottom:22px}
37
+ .steps div{background:var(--pn2);border:1px solid var(--ln);border-radius:var(--r);padding:12px}
38
+ .steps b{display:block;margin-bottom:3px;color:var(--ac)}
39
+ .steps span{color:var(--mu);font-size:12.5px}
40
+ #sw.drag #empty>div{outline:2px dashed var(--ac);outline-offset:12px}
41
+ aside{background:var(--pn);border-left:1px solid var(--ln);overflow:auto;padding:16px 16px 24px}
42
+ h3{font:600 15px "Bricolage Grotesque",sans-serif;letter-spacing:-.01em;margin:20px 0 9px;padding-top:14px;border-top:1px solid var(--ln)}
43
+ h3:first-child{margin-top:0;padding-top:0;border-top:0}
44
+ .row{display:flex;gap:6px;flex-wrap:wrap}
45
+ .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}
46
+ .lay.on{border-color:var(--ac);box-shadow:inset 3px 0 var(--ac)}
47
+ .lay b{flex:1;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
48
+ .lay.hid b{opacity:.4;text-decoration:line-through}
49
+ .lay button{border:0;background:none;padding:3px 7px;color:var(--mu)}
50
+ .lay button:hover{color:var(--tx)}
51
+ .f{display:grid;grid-template-columns:70px 1fr 44px;align-items:center;gap:6px;margin:7px 0}
52
+ .f output{text-align:right;color:var(--mu);font-size:12px}
53
+ input[type=range]{width:100%;accent-color:var(--ac)}
54
+ input[type=checkbox]{accent-color:var(--ac)}
55
+ .t{display:flex;align-items:center;gap:8px;margin:7px 0}
56
+ 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)}
57
+ input[type=color]{width:40px;height:30px;padding:0;border:1px solid var(--ln);border-radius:var(--r);background:var(--pn2)}
58
+ .grid{display:grid;grid-template-columns:repeat(3,28px);gap:3px}
59
+ .grid button{height:28px;padding:0;position:relative}
60
+ .grid button:after{content:"";position:absolute;width:8px;height:8px;border-radius:2px;background:var(--mu)}
61
+ .grid button.on{border-color:var(--ac)}.grid button.on:after{background:var(--ac)}
62
+ .grid [data-a^=t]:after{top:4px}.grid [data-a^=b]:after{bottom:4px}.grid [data-a^=m]:after{top:9px}
63
+ .grid [data-a$=l]:after{left:4px}.grid [data-a$=r]:after{right:4px}.grid [data-a$=c]:after{left:9px}
64
+ .pos{display:flex;gap:14px;align-items:center}
65
+ .seg{display:flex;border:1px solid var(--ln);border-radius:var(--r);overflow:hidden;background:var(--pn2)}
66
+ .seg button{flex:1;border:0;border-radius:0;background:none;padding:6px 4px}
67
+ .seg button.on{background:var(--tx);color:#fff;font-weight:600}
68
+ .note{color:var(--mu);font-size:12px;margin:6px 0}
69
+ #strip{display:flex;gap:10px;padding:25px 16px;overflow-x:auto;
70
+ 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}
71
+ .th{flex:none;height:100%;border:0;padding:0;background:none;position:relative;outline-offset:3px;border-radius:2px;text-align:left}
72
+ .th.on{outline:2px solid var(--ac)}
73
+ .th canvas{display:block;height:calc(100% - 17px);width:auto;max-width:150px;border-radius:2px}
74
+ .th span{display:block;font-size:11px;color:#b9bdc2;max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:2px}
75
+ .th em{position:absolute;top:4px;right:4px;width:9px;height:9px;border-radius:50%;background:var(--ac);border:1.5px solid #fff}
76
+ #prog{position:fixed;left:0;top:0;height:3px;background:var(--ac);width:0;transition:width .2s;z-index:99}
77
+ #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}
78
+ #toast.show{opacity:1}
79
+ #dlg{position:fixed;inset:0;background:#0008;display:none;place-items:center;z-index:50}
80
+ #dlg.show{display:grid}
81
+ #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}
82
+ #dh,#df{padding:12px 16px;display:flex;gap:8px;align-items:center}
83
+ #dh{border-bottom:1px solid var(--ln)}#df{border-top:1px solid var(--ln)}
84
+ #db{padding:14px 16px;overflow:auto;flex:1;min-height:200px}
85
+ .dg{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px}
86
+ .dt{border:2px solid var(--ln);border-radius:var(--r);background:var(--pn2);padding:0;overflow:hidden;text-align:left;cursor:pointer}
87
+ .dt.on{border-color:var(--ac)}
88
+ .dt img,.dt .ph{display:grid;place-items:center;width:100%;height:84px;object-fit:cover;background:var(--bg);font-size:26px}
89
+ .dt span{display:block;padding:4px 6px;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
90
+ .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)}
91
+ @media(max-width:860px){body{overflow:auto;display:block}main{display:block}#sw{height:60vh}#strip{height:144px}}
92
+ @media(prefers-reduced-motion:reduce){*{transition:none!important}}
93
+ </style>
94
+ </head>
95
+ <body>
96
+ <div id="prog"></div>
97
+ <header>
98
+ <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>
99
+ <button id="bPhotos">Add photos</button>
100
+ <button id="bFolder">Add folder</button>
101
+ <button id="bDrive">Google Drive</button>
102
+ <span class="sp"></span>
103
+ <span class="mu" id="count"></span>
104
+ <button class="go" id="bExport" disabled>Export all</button>
105
+ </header>
106
+ <main>
107
+ <section id="sw">
108
+ <div id="stage"><canvas id="cv" hidden></canvas></div>
109
+ <div id="empty"><div>
110
+ <h2>Stamp a whole shoot in one go</h2>
111
+ <p>Drop photos anywhere on this page, or use the buttons above.</p>
112
+ <div class="steps">
113
+ <div><b>1. Add photos</b><span>From your computer, a folder, or Google Drive.</span></div>
114
+ <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>
115
+ <div><b>3. Place and export</b><span>Drag, resize and rotate it once. Every photo follows.</span></div>
116
+ </div>
117
+ <button class="go" id="bPhotos2">Choose photos</button>
118
+ </div></div>
119
+ </section>
120
+ <aside>
121
+ <h3>Watermarks</h3>
122
+ <div class="row" style="margin-bottom:8px"><button id="bLogo">Add image</button><button id="bText">Add text</button></div>
123
+ <div id="layers"></div>
124
+ <div id="ctl"></div>
125
+ <h3>My changes apply to</h3>
126
+ <div class="seg" id="scope"><button data-v="0" class="on">All photos</button><button data-v="1">This photo only</button></div>
127
+ <div class="row" style="margin-top:8px"><button id="bReset">Reset this photo</button></div>
128
+ <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>
129
+ <h3>Export</h3>
130
+ <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>
131
+ <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>
132
+ <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>
133
+ <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>
134
+ <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>
135
+ </aside>
136
+ </main>
137
+ <footer id="strip" aria-label="Photos"></footer>
138
+ <div id="toast" role="status"></div>
139
+ <div id="dlg"><div><div id="dh"></div><div id="db"></div><div id="df"></div></div></div>
140
+ <input type="file" id="fPhotos" accept="image/*" multiple hidden>
141
+ <input type="file" id="fFolder" webkitdirectory multiple hidden>
142
+ <input type="file" id="fLogo" accept="image/*" multiple hidden>
143
+ <script src="render.js"></script>
144
+ <script>
145
+ const $=s=>document.querySelector(s);
146
+ const S={items:[],layers:[],cur:-1,sel:null,only:false};
147
+ let uid=1,cvW=0,cvH=0,guides={v:0,h:0};
148
+ const cv=$('#cv'),cx=cv.getContext('2d');
149
+ const FONTS=['Hanken Grotesk','Georgia','Arial','Courier New','Impact','Noto Sans Sinhala','Iskoola Pota'];
150
+ const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
151
+ const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
152
+ function toast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');clearTimeout(toast.t);toast.t=setTimeout(()=>t.classList.remove('show'),2800)}
153
+ const cur=()=>S.layers.find(l=>l.id===S.sel);
154
+
155
+ /* ---------- rendering ---------- */
156
+ 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]];
157
+ function render(c,src,w,h,item,ui){
158
+ c.clearRect(0,0,w,h);c.drawImage(src,0,0,w,h);
159
+ let sb=null;
160
+ 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}
161
+ if(!ui)return;
162
+ c.save();c.lineWidth=1;c.setLineDash([5,4]);c.strokeStyle='#e5195f';
163
+ if(guides.v){c.beginPath();c.moveTo(w/2,0);c.lineTo(w/2,h);c.stroke()}
164
+ if(guides.h){c.beginPath();c.moveTo(0,h/2);c.lineTo(w,h/2);c.stroke()}
165
+ c.restore();
166
+ if(!sb)return;
167
+ const{x,y,ww,hh,E}=sb;c.save();c.translate(x,y);c.rotate(E.rot*Math.PI/180);
168
+ const box=()=>c.strokeRect(-ww/2,-hh/2,ww,hh);
169
+ c.lineWidth=3;c.strokeStyle='#000a';box();c.lineWidth=1.5;c.strokeStyle='#e5195f';box();
170
+ c.beginPath();c.moveTo(0,-hh/2);c.lineTo(0,-hh/2-26);c.stroke();
171
+ c.fillStyle='#e5195f';c.strokeStyle='#ffffff';c.lineWidth=1.5;
172
+ 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()}
173
+ c.beginPath();c.arc(0,-hh/2-28,6,0,7);c.fill();c.stroke();
174
+ c.restore();
175
+ 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;
176
+ 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)}
177
+ }
178
+ let raf=0,thT=0;
179
+ function draw(){cancelAnimationFrame(raf);raf=requestAnimationFrame(()=>{const it=S.items[S.cur];if(it)render(cx,it.bmp,cvW,cvH,it,true)})}
180
+ 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)}
181
+ const redraw=()=>{draw();drawThumbs()};
182
+ function fit(){
183
+ const it=S.items[S.cur];if(!it)return;
184
+ const b=$('#stage').getBoundingClientRect(),s=Math.min((b.width-52)/it.w,(b.height-52)/it.h,1);
185
+ cvW=Math.max(1,Math.round(it.w*s));cvH=Math.max(1,Math.round(it.h*s));
186
+ 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';
187
+ cx.setTransform(d,0,0,d,0,0);draw()
188
+ }
189
+ addEventListener('resize',fit);
190
+
191
+ /* ---------- property edits (respect "all photos" / "this photo") ---------- */
192
+ function setProp(L,it,props){
193
+ if(S.only&&it){it.ov[L.id]=Object.assign(it.ov[L.id]||{},props)}
194
+ else{Object.assign(L,props);const pos='anchor'in props||'x'in props;
195
+ 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]})}
196
+ }
197
+
198
+ /* ---------- photos ---------- */
199
+ async function loadAny(f){
200
+ try{return await createImageBitmap(f)}catch(e1){}
201
+ const viaUrl=async u=>{const im=new Image();im.src=u;await im.decode();return im};
202
+ try{return await viaUrl(URL.createObjectURL(f))}catch(e2){}
203
+ 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)});
204
+ 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.')}
205
+ }
206
+
207
+ async function addPhotos(files){
208
+ files=[...files].filter(f=>f.type.startsWith('image/')||/\.(jpe?g|png|webp|gif|bmp|avif)$/i.test(f.name));
209
+ if(!files.length){toast('No images found in that selection.');return}
210
+ let n=0;
211
+ for(const f of files){
212
+ try{
213
+ const bmp=await loadAny(f),k=Math.min(1,300/Math.max(bmp.width,bmp.height)),th=document.createElement('canvas');
214
+ 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);
215
+ 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;
216
+ if(n%6===0){buildStrip();updateUI();fit()}
217
+ }catch(err){toast('Could not open '+f.name+': '+(err.message||err))}
218
+ }
219
+ buildStrip();updateUI();fit()
220
+ }
221
+ function buildStrip(){
222
+ const s=$('#strip');s.innerHTML='';
223
+ S.items.forEach((it,i)=>{
224
+ const b=document.createElement('button');b.className='th'+(i===S.cur?' on':'');
225
+ const tc=document.createElement('canvas');tc.width=it.th.width;tc.height=it.th.height;it.tc=tc;
226
+ render(tc.getContext('2d'),it.th,tc.width,tc.height,it,false);
227
+ const sp=document.createElement('span');sp.textContent=it.name;b.append(tc,sp);
228
+ if(Object.keys(it.ov).length){const e=document.createElement('em');e.title='Custom placement';b.append(e)}
229
+ b.onclick=()=>{S.cur=i;buildStrip();fit()};s.append(b)});
230
+ const on=s.querySelector('.on');if(on)on.scrollIntoView({inline:'nearest',block:'nearest'})
231
+ }
232
+ function updateUI(){
233
+ const has=S.items.length>0;$('#empty').hidden=has;cv.hidden=!has;$('#bExport').disabled=!has;
234
+ $('#count').textContent=has?`${S.items.length} photo${S.items.length>1?'s':''}`:''
235
+ }
236
+ $('#bPhotos').onclick=$('#bPhotos2').onclick=()=>$('#fPhotos').click();
237
+ $('#bFolder').onclick=()=>$('#fFolder').click();
238
+ $('#fPhotos').onchange=e=>{addPhotos(e.target.files);e.target.value=''};
239
+ $('#fFolder').onchange=e=>{addPhotos(e.target.files);e.target.value=''};
240
+ ['dragenter','dragover'].forEach(t=>addEventListener(t,e=>{e.preventDefault();$('#sw').classList.add('drag')}));
241
+ ['dragleave','drop'].forEach(t=>addEventListener(t,e=>{e.preventDefault();$('#sw').classList.remove('drag')}));
242
+ addEventListener('drop',e=>{if(e.dataTransfer.files.length)addPhotos(e.dataTransfer.files)});
243
+
244
+ /* ---------- layers + properties ---------- */
245
+ function newLayer(o){
246
+ 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};
247
+ S.layers.push(L);S.sel=L.id;buildLayers();redraw()
248
+ }
249
+ $('#bText').onclick=()=>newLayer({type:'text',name:'Text',size:.28});
250
+ $('#bLogo').onclick=()=>$('#fLogo').click();
251
+ $('#fLogo').onchange=async e=>{
252
+ const list=[...e.target.files];e.target.value='';
253
+ for(const f of list){
254
+ try{addLogo(await loadAny(f),f.name.replace(/\.[^.]+$/,''))}catch(err){toast('Could not open '+f.name+': '+(err.message||err))}}
255
+ };
256
+ function buildLayers(){
257
+ const box=$('#layers');box.innerHTML='';
258
+ if(!S.layers.length)box.innerHTML='<p class="note">No watermark yet. A PNG with a transparent background looks best.</p>';
259
+ [...S.layers].reverse().forEach(L=>{
260
+ const d=document.createElement('div');d.className='lay'+(L.id===S.sel?' on':'')+(L.vis?'':' hid');
261
+ 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>';
262
+ d.firstChild.textContent=L.type==='text'?'“'+L.text+'”':L.name;
263
+ d.onclick=e=>{const x=e.target.dataset.x,i=S.layers.indexOf(L);
264
+ if(x==='v')L.vis=!L.vis;
265
+ else if(x==='u'&&i<S.layers.length-1)[S.layers[i],S.layers[i+1]]=[S.layers[i+1],S.layers[i]];
266
+ else if(x==='d'&&i>0)[S.layers[i],S.layers[i-1]]=[S.layers[i-1],S.layers[i]];
267
+ else if(x==='r')removeLayer(L);
268
+ else if(!x)S.sel=L.id;
269
+ buildLayers();redraw()};
270
+ box.append(d)});
271
+ buildCtl()
272
+ }
273
+ 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}
274
+ 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>`}
275
+ function buildCtl(){
276
+ const L=cur(),b=$('#ctl');if(!L){b.innerHTML='';return}
277
+ const it=S.items[S.cur],E=eff(L,it);
278
+ 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>';
279
+ 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>`;
280
+ 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);
281
+ 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>`;
282
+ if(L.tile)h+=sl('gap','Spacing',0,300,5,100,'%',L);
283
+ b.innerHTML=h;
284
+ b.querySelectorAll('[data-a]').forEach(x=>x.onclick=()=>{setProp(L,S.items[S.cur],{anchor:x.dataset.a});buildCtl();buildStrip();redraw()});
285
+ b.querySelectorAll('input[type=range]').forEach(x=>x.oninput=()=>{
286
+ const k=x.dataset.k,v=x.value/x.dataset.m;
287
+ if(k==='size'||k==='rot')setProp(L,S.items[S.cur],{[k]:v});else{L[k]=v;if(k==='tol')cutApply(L)}
288
+ x.nextElementSibling.textContent=Math.round(x.value)+(k==='rot'?'°':'%');redraw()});
289
+ const t=$('#c_text');if(t)t.oninput=()=>{L.text=t.value;redraw();const n=$('#layers .on b');if(n)n.textContent='“'+L.text+'”'};
290
+ const c=$('#c_color');if(c)c.oninput=()=>{L.color=c.value;redraw()};
291
+ const f=$('#c_font');if(f)f.onchange=()=>{L.font=f.value;redraw()};
292
+ $('#c_shadow').onchange=e=>{L.shadow=e.target.checked;redraw()};
293
+ $('#c_tile').onchange=e=>{L.tile=e.target.checked;buildCtl();redraw()};
294
+ const ft=$('#c_fit');if(ft)ft.onclick=()=>{setProp(L,S.items[S.cur],{size:.98,anchor:'bc'});buildCtl();buildStrip();redraw()};
295
+ const cc=$('#c_cut');if(cc)cc.onchange=e=>{L.cut=e.target.checked;cutApply(L);buildCtl();redraw()}
296
+ }
297
+ $('#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))};
298
+ $('#bReset').onclick=()=>{const it=S.items[S.cur];if(it){it.ov={};buildStrip();buildCtl();redraw()}};
299
+
300
+ /* ---------- mouse: move / resize / rotate ---------- */
301
+ let drag=null;
302
+ const pt=e=>{const r=cv.getBoundingClientRect();return{x:(e.clientX-r.left)*cvW/r.width,y:(e.clientY-r.top)*cvH/r.height}};
303
+ function geo(L,it){const E=eff(L,it),m=metrics(E,cx,cvW,cvH);return{E,m,...place(E,m,cvW,cvH)}}
304
+ 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)}}
305
+ function handleAt(p,it){
306
+ const L=cur();if(!L||!L.vis)return null;const g=geo(L,it),q=local(g,p);
307
+ if(Math.hypot(q.x,q.y+g.m.hh/2+28)<=10)return'rot';
308
+ 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;
309
+ return null
310
+ }
311
+ function bodyAt(p,it){
312
+ for(const L of[...S.layers].reverse()){if(!L.vis)continue;const g=geo(L,it),q=local(g,p);
313
+ 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}
314
+ return null
315
+ }
316
+ cv.onpointerdown=e=>{
317
+ const it=S.items[S.cur];if(!it)return;const p=pt(e);
318
+ const hn=handleAt(p,it);let L=cur();
319
+ 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()}}
320
+ const g=geo(L,it);
321
+ setProp(L,it,{anchor:null,x:g.x/cvW,y:g.y/cvH}); // freeze position so resize/rotate stay put
322
+ 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};
323
+ cv.setPointerCapture(e.pointerId);draw()
324
+ };
325
+ cv.onpointermove=e=>{
326
+ const it=S.items[S.cur];if(!it)return;const p=pt(e);
327
+ if(!drag){const h=handleAt(p,it);
328
+ 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}
329
+ const L=drag.L;
330
+ if(drag.mode==='move'){let x=(p.x-drag.dx)/cvW,y=(p.y-drag.dy)/cvH;
331
+ guides.v=Math.abs(x-.5)<.012;guides.h=Math.abs(y-.5)<.012;if(guides.v)x=.5;if(guides.h)y=.5;
332
+ setProp(L,it,{anchor:null,x:clamp(x,0,1),y:clamp(y,0,1)})}
333
+ 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)});
334
+ 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})}
335
+ draw()
336
+ };
337
+ cv.onpointerup=cv.onpointercancel=()=>{if(drag){drag=null;guides={v:0,h:0};buildCtl();buildStrip();redraw()}};
338
+ addEventListener('keydown',e=>{
339
+ if(/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)||$('#dlg').classList.contains('show'))return;
340
+ const L=cur(),it=S.items[S.cur];if(!L||!it)return;
341
+ if(e.key==='Delete'||e.key==='Backspace'){removeLayer(L);buildLayers();redraw();return}
342
+ const d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];if(!d)return;
343
+ e.preventDefault();const g=geo(L,it),s=(e.shiftKey?.01:.002);
344
+ 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()
345
+ });
346
+
347
+ /* ---------- Google Drive ---------- */
348
+ const DR={tok:null,tab:'sharedWithMe',path:[],sel:new Map(),files:[]};
349
+ const DX={tok:null,email:''};
350
+ let CFG={};fetch('/api/config').then(r=>r.json()).then(j=>CFG=j).catch(()=>{});
351
+ const cid=()=>CFG.clientId||ls('mr_cid');
352
+ 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})();
353
+ 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)})}
354
+ async function readNDJSON(r,on){
355
+ const rd=r.body.getReader(),dec=new TextDecoder();let buf='';
356
+ 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))}}
357
+ if(buf.trim())on(JSON.parse(buf))
358
+ }
359
+ const ls=(k,v)=>{try{if(v===undefined)return localStorage.getItem(k);localStorage.setItem(k,v)}catch{return null}};
360
+ function dlg(show){$('#dlg').classList.toggle('show',show)}
361
+ $('#dlg').onclick=e=>{if(e.target.id==='dlg')dlg(false)};
362
+ $('#bDrive').onclick=()=>{dlg(true);cid()?driveConnect():driveSetup()};
363
+ function driveSetup(){
364
+ $('#dh').innerHTML='<b style="flex:1">Connect Google Drive</b><button id="dx">Close</button>';
365
+ $('#db').innerHTML=`<div class="dlgs"><p class="mu">One-time setup so this page can read your Drive photos (about 5 minutes).</p><ol>
366
+ <li>Open <code>console.cloud.google.com</code>, create a project, turn on <b>Google Drive API</b>.</li>
367
+ <li>OAuth consent screen: add your Google accounts as <b>Test users</b>.</li>
368
+ <li>Credentials → Create credentials → <b>OAuth client ID</b> → Web application.</li>
369
+ <li>Under Authorized JavaScript origins add <code>${esc(location.origin)}</code>.</li>
370
+ <li>Best: put the Client ID in the Space variable <code>GOOGLE_CLIENT_ID</code>. Or paste it here:</li></ol>
371
+ <div class="t"><input type="text" id="cid" placeholder="123456-abc.apps.googleusercontent.com" value="${esc(ls('mr_cid')||'')}"></div>
372
+ ${location.protocol==='file:'?'<p class="note" style="color:var(--ac)">You opened this as a file. Open the hosted Space URL instead.</p>':''}</div>`;
373
+ $('#df').innerHTML='<span class="sp"></span><button class="go" id="dsave">Save and connect</button>';
374
+ $('#dx').onclick=()=>dlg(false);
375
+ $('#dsave').onclick=()=>{const v=$('#cid').value.trim();if(!v)return;ls('mr_cid',v);driveConnect()}
376
+ }
377
+ async function driveConnect(){
378
+ if(location.protocol==='file:'){driveSetup();return}
379
+ try{await loadGIS()}catch(e){toast(e.message);return}
380
+ DR.tc=google.accounts.oauth2.initTokenClient({client_id:cid(),scope:'https://www.googleapis.com/auth/drive.readonly',
381
+ callback:r=>{if(r.access_token){DR.tok=r.access_token;DR.path=[];driveList()}else driveSetup()}});
382
+ DR.tc.requestAccessToken()
383
+ }
384
+ async function driveList(){
385
+ $('#db').innerHTML='<p class="mu">Loading…</p>';
386
+ const parent=DR.path.length?DR.path[DR.path.length-1].id:null;
387
+ 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/')";
388
+ try{
389
+ let files=[],tok='';
390
+ 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}});
391
+ if(!r.ok)throw new Error(r.status===401?'Session expired. Reconnect.':'Drive returned '+r.status);
392
+ const j=await r.json();files=files.concat(j.files);tok=j.nextPageToken||''}while(tok&&files.length<2000);
393
+ DR.files=files;driveView()
394
+ }catch(err){$('#db').innerHTML='<p style="color:var(--ac)">'+esc(err.message)+'</p>'}
395
+ }
396
+ function driveView(){
397
+ const imgs=DR.files.filter(f=>f.mimeType.startsWith('image/'));
398
+ $('#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>`;
399
+ $('#dh').querySelectorAll('[data-t]').forEach(b=>b.onclick=()=>{DR.tab=b.dataset.t;DR.path=[];driveList()});
400
+ $('#crumb').textContent=DR.path.map(p=>p.name).join(' / ');
401
+ const g=document.createElement('div');g.className='dg';
402
+ 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)}
403
+ DR.files.forEach(f=>{const b=document.createElement('button');const isF=f.mimeType.includes('folder');
404
+ b.className='dt'+(DR.sel.has(f.id)?' on':'');
405
+ 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>';
406
+ 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)});
407
+ $('#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);
408
+ $('#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>';
409
+ const upd=()=>{$('#dn').textContent=DR.sel.size+' selected';$('#dgo').disabled=!DR.sel.size};upd();
410
+ $('#dall').onclick=()=>{imgs.forEach(f=>DR.sel.set(f.id,f));driveView()};
411
+ $('#dx').onclick=()=>dlg(false);
412
+ $('#dgo').onclick=importFromDrive;
413
+ }
414
+ async function addServerItem(info){
415
+ const r=await fetch(`/api/preview/${SID}/${info.rid}`);if(!r.ok)throw new Error('preview '+r.status);
416
+ const bmp=await createImageBitmap(await r.blob());
417
+ const k=Math.min(1,300/Math.max(bmp.width,bmp.height)),th=document.createElement('canvas');
418
+ 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);
419
+ S.items.push({id:uid++,name:info.name,bmp,w:info.w,h:info.h,th,ov:{},rid:info.rid});
420
+ if(S.cur<0)S.cur=0
421
+ }
422
+ async function importFromDrive(){
423
+ const list=[...DR.sel.values()];if(!list.length)return;
424
+ DR.sel.clear();dlg(false);
425
+ let done=0,ok=0,rt=0;const tasks=[];
426
+ const refresh=()=>{clearTimeout(rt);rt=setTimeout(()=>{buildStrip();updateUI();fit()},120)};
427
+ const bar=()=>{$('#prog').style.width=(done/list.length*100)+'%';$('#count').textContent=`Importing ${done} / ${list.length}`};
428
+ bar();
429
+ try{
430
+ 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}))})});
431
+ if(!r.ok)throw new Error(r.status===401?'Session expired. Click Google Drive and reconnect.':'Server error '+r.status);
432
+ await readNDJSON(r,m=>{
433
+ if(m.t==='item')tasks.push(addServerItem(m).then(()=>{ok++}).catch(e=>toast('Could not open '+m.name+': '+e.message)).finally(()=>{done++;bar();refresh()}));
434
+ else if(m.t==='err'){done++;bar();toast('Could not download '+m.name+': '+m.msg)}
435
+ });
436
+ await Promise.all(tasks)
437
+ }catch(e){toast(e.message)}
438
+ clearTimeout(rt);buildStrip();updateUI();fit();$('#prog').style.width='0';
439
+ toast(`Imported ${ok} photo${ok===1?'':'s'} from Drive.`)
440
+ }
441
+
442
+ /* ---------- export ---------- */
443
+ 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})();
444
+ 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};
445
+ function zip(files){
446
+ const enc=new TextEncoder(),parts=[],cen=[];let off=0;
447
+ 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);
448
+ for(const f of files){
449
+ const nm=enc.encode(f.name),c=crc(f.data),h=new DataView(new ArrayBuffer(30)),n=f.data.length;
450
+ 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);
451
+ parts.push(h.buffer,nm,f.data);
452
+ const g=new DataView(new ArrayBuffer(46));
453
+ 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);
454
+ cen.push(g.buffer,nm);off+=30+nm.length+n}
455
+ const cs=cen.reduce((a,b)=>a+b.byteLength,0),e=new DataView(new ArrayBuffer(22));
456
+ 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);
457
+ return new Blob([...parts,...cen,e.buffer],{type:'application/zip'})
458
+ }
459
+ 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)}
460
+ $('#xq').oninput=e=>$('#xqo').textContent=e.target.value;
461
+ $('#xd').onchange=()=>{$('#xdrv').hidden=$('#xd').value!=='drive';if(!$('#xfn').value)$('#xfn').value='Markroom '+new Date().toISOString().slice(0,10)};
462
+ async function exportConnect(pick){
463
+ if(!cid()){driveSetup();dlg(true);throw new Error('Set up the Google Client ID first.')}
464
+ await loadGIS();
465
+ const tok=await new Promise((res,rej)=>{
466
+ const tc=google.accounts.oauth2.initTokenClient({client_id:cid(),scope:'https://www.googleapis.com/auth/drive.file email',prompt:pick?'select_account':'',
467
+ callback:r=>r.access_token?res(r.access_token):rej(new Error(r.error||'Google sign-in failed')),
468
+ error_callback:e=>rej(new Error(e.type==='popup_closed'?'Sign-in window was closed.':'Google sign-in failed'))});
469
+ tc.requestAccessToken()});
470
+ DX.tok=tok;DX.email='';
471
+ try{const u=await (await fetch('https://www.googleapis.com/oauth2/v3/userinfo',{headers:{Authorization:'Bearer '+tok}})).json();DX.email=u.email||''}catch{}
472
+ $('#xacc').textContent=DX.email?'Exporting to the Drive of '+DX.email+'. Use the button to switch account.':'Google account connected.';
473
+ return tok
474
+ }
475
+ $('#bXAcc').onclick=()=>exportConnect(true).catch(e=>toast(e.message));
476
+ async function gFolder(tok,name){
477
+ 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'})});
478
+ if(!r.ok)throw new Error(r.status===401?'Google session expired. Choose the account again.':'Could not create the folder ('+r.status+')');
479
+ return(await r.json()).id
480
+ }
481
+ async function gUpload(tok,folderId,name,blob){
482
+ 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]})});
483
+ if(!i.ok)throw new Error('Drive '+i.status);
484
+ const p=await fetch(i.headers.get('Location'),{method:'PUT',headers:{'Content-Type':blob.type},body:blob});
485
+ if(!p.ok)throw new Error('Drive upload '+p.status)
486
+ }
487
+ async function pushSettings(){
488
+ const keys=['id','type','size','opacity','rot','margin','anchor','x','y','tile','gap','shadow','vis','text','font','color','weight'];
489
+ 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});
490
+ const r=await fetch(`/api/settings/${SID}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layers})});
491
+ if(!r.ok)throw new Error('Could not send watermark settings to the server');
492
+ }
493
+ async function serverRender(it,o){
494
+ const r=await fetch(`/api/render/${SID}/${it.rid}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ov:it.ov,...o})});
495
+ if(!r.ok)throw new Error(r.status===404?'Server forgot this photo (Space restarted). Import it again.':await r.text());
496
+ return r.blob()
497
+ }
498
+ async function localRender(it,max,mime,q){
499
+ 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');
500
+ c.width=w;c.height=h;render(c.getContext('2d'),it.bmp,w,h,it,false);
501
+ return new Promise(r=>c.toBlob(r,mime,q))
502
+ }
503
+ $('#bExport').onclick=async()=>{
504
+ if(!S.layers.some(l=>l.vis)&&!confirm('No watermark is visible. Export the photos anyway?'))return;
505
+ const png=$('#xf').value==='png',q=$('#xq').value/100,max=+$('#xs').value,mode=$('#xd').value,btn=$('#bExport');
506
+ const mime=png?'image/png':'image/jpeg',ext=png?'.png':'.jpg',used=new Set(),N=S.items.length;btn.disabled=true;
507
+ 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});
508
+ let done=0;const prog=()=>{btn.textContent=`Exporting ${done} / ${N}`;$('#prog').style.width=(done/N*100)+'%'};
509
+ try{
510
+ if(S.items.some(i=>i.rid))await pushSettings();
511
+ if(mode==='drive'){
512
+ const tok=DX.tok||await exportConnect(false),fname=$('#xfn').value.trim()||'Markroom export';
513
+ const folderId=await gFolder(tok,fname);let ok=0,fail=0;prog();
514
+ const srv=S.items.map((it,i)=>[it,i]).filter(([it])=>it.rid),jobs=[];
515
+ if(srv.length)jobs.push((async()=>{
516
+ 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}))})});
517
+ if(!r.ok)throw new Error(await r.text());
518
+ 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+')')}})
519
+ })());
520
+ jobs.push((async()=>{for(let i=0;i<N;i++){const it=S.items[i];if(it.rid)continue;
521
+ try{await gUpload(tok,folderId,names[i],await localRender(it,max,mime,q));ok++}catch(e){fail++;toast('Failed: '+names[i]+' ('+e.message+')')}
522
+ done++;prog()}})());
523
+ await Promise.all(jobs);
524
+ dlg(true);$('#dh').innerHTML='<b style="flex:1">Exported to Google Drive</b><button id="dx">Close</button>';
525
+ $('#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>`;
526
+ $('#df').innerHTML='';$('#dx').onclick=()=>dlg(false)
527
+ }else{
528
+ const files=[];
529
+ for(let i=0;i<N;i++){const it=S.items[i];
530
+ const blob=it.rid?await serverRender(it,{fmt:png?'png':'jpg',q,max}):await localRender(it,max,mime,q);
531
+ files.push({name:names[i],data:new Uint8Array(await blob.arrayBuffer())});done++;prog()}
532
+ if(mode==='zip')save(zip(files),'watermarked.zip');
533
+ else for(const f of files){save(new Blob([f.data],{type:mime}),f.name);await new Promise(r=>setTimeout(r,250))}
534
+ toast(`Exported ${files.length} photo${files.length>1?'s':''}.`)
535
+ }
536
+ }catch(err){toast('Export stopped: '+err.message)}
537
+ btn.textContent='Export all';btn.disabled=false;setTimeout(()=>$('#prog').style.width='0',800)
538
+ };
539
+ /* ---------- white-background removal for logos (JPG has no transparency) ---------- */
540
+ function mkBase(el){
541
+ const W=el.naturalWidth||el.width,H=el.naturalHeight||el.height,k=Math.min(1,3000/Math.max(W,H)),c=document.createElement('canvas');
542
+ c.width=Math.max(1,Math.round(W*k));c.height=Math.max(1,Math.round(H*k));
543
+ c.getContext('2d').drawImage(el,0,0,c.width,c.height);return c
544
+ }
545
+ function autoCut(c){
546
+ const w=c.width,h=c.height,d=c.getContext('2d').getImageData(0,0,w,h).data;
547
+ for(let i=3;i<d.length;i+=4)if(d[i]<250)return false;
548
+ 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})
549
+ }
550
+ function cutWhite(base,tol){
551
+ 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;
552
+ const seen=new Uint8Array(w*h),st=[],mn=i=>Math.min(d[i*4],d[i*4+1],d[i*4+2]);
553
+ const push=i=>{if(!seen[i]&&mn(i)>=thr){seen[i]=1;st.push(i)}};
554
+ 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)}
555
+ 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)}
556
+ const a=new Uint8ClampedArray(w*h).fill(255);
557
+ for(let i=0;i<w*h;i++){
558
+ if(seen[i]){a[i]=0;continue}
559
+ const x=i%w,y=(i/w)|0;
560
+ 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))
561
+ }
562
+ for(let i=0;i<w*h;i++)d[i*4+3]=a[i];
563
+ const o=document.createElement('canvas');o.width=w;o.height=h;o.getContext('2d').putImageData(new ImageData(d,w,h),0,0);return o
564
+ }
565
+ function cutApply(o){o.cv=o.cut?cutWhite(o.base,o.tol):o.base}
566
+ function addLogo(el,name){
567
+ const base=mkBase(el);let cut=false;try{cut=autoCut(base)}catch(e){}
568
+ const o={name,base,cut,tol:30,opacity:1,cv:base};
569
+ if(base.width/base.height>6)Object.assign(o,{size:.96,anchor:'bc',margin:.01});
570
+ try{cutApply(o)}catch(e){o.cut=false;o.cv=base}
571
+ newLayer(o);
572
+ if(cut)toast('White background removed. You can turn this off in the watermark settings.')
573
+ }
574
+ buildLayers();updateUI();
575
+ </script>
576
+ </body>
577
+ </html>
public/render.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Shared by the browser (live preview) and the server (full-size export), so both draw identically. */
2
+ (function(root){
3
+ const eff=(L,it)=>{const o=it&&it.ov&&it.ov[L.id];return o?{...L,...o}:L};
4
+ function metrics(E,c,w,h){
5
+ const ww=E.size*w;let hh,fs=0;
6
+ if(E.type==='image')hh=ww*E.cv.height/E.cv.width;
7
+ else{c.font=`${E.weight} 100px "${E.font}",sans-serif`;fs=ww/Math.max(1,c.measureText(E.text||' ').width)*100;hh=fs*1.15}
8
+ return{ww,hh,fs}
9
+ }
10
+ function place(E,m,w,h){
11
+ const mg=E.margin*w;
12
+ if(E.anchor){const a=E.anchor;return{x:a[1]==='l'?mg+m.ww/2:a[1]==='r'?w-mg-m.ww/2:w/2,y:a[0]==='t'?mg+m.hh/2:a[0]==='b'?h-mg-m.hh/2:h/2}}
13
+ return{x:E.x*w,y:E.y*h}
14
+ }
15
+ function drawLayer(c,L,item,w,h){
16
+ const E=eff(L,item),m=metrics(E,c,w,h),p=place(E,m,w,h);
17
+ c.save();c.globalAlpha=E.opacity;c.translate(p.x,p.y);c.rotate(E.rot*Math.PI/180);
18
+ if(E.shadow){c.shadowColor='rgba(0,0,0,.5)';c.shadowBlur=m.ww*.03;c.shadowOffsetY=m.ww*.008}
19
+ const stamp=(x,y)=>{
20
+ if(E.type==='image')c.drawImage(E.cv,x-m.ww/2,y-m.hh/2,m.ww,m.hh);
21
+ else{c.font=`${E.weight} ${m.fs}px "${E.font}",sans-serif`;c.fillStyle=E.color;c.textAlign='center';c.textBaseline='middle';c.fillText(E.text,x,y)}};
22
+ if(E.tile){const sx=m.ww*(1+E.gap),sy=m.hh*(1+E.gap),ext=Math.hypot(w,h),nx=Math.ceil(ext/sx)+1,ny=Math.ceil(ext/sy)+1;
23
+ for(let j=-ny;j<=ny;j++)for(let i=-nx;i<=nx;i++)stamp(i*sx+(j&1?sx/2:0),j*sy)}
24
+ else stamp(0,0);
25
+ c.restore();return{...m,...p,E}
26
+ }
27
+
28
+ function drawAll(c,w,h,layers,item){for(const L of layers){if(L.vis)drawLayer(c,L,item,w,h)}}
29
+ const api={eff,metrics,place,drawLayer,drawAll};
30
+ if(typeof module!=='undefined'&&module.exports)module.exports=api;else Object.assign(root,api);
31
+ })(typeof window!=='undefined'?window:globalThis);
public/server.js ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const sharp = require('sharp');
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { Readable } = require('stream');
7
+ const { pipeline } = require('stream/promises');
8
+ const { createCanvas, loadImage, ImageData, GlobalFonts } = require('@napi-rs/canvas');
9
+ /* Works with a public/ folder or with all files uploaded flat into the repo root */
10
+ const PUB = fs.existsSync(path.join(__dirname, 'public', 'render.js')) ? path.join(__dirname, 'public') : __dirname;
11
+ const R = require(path.join(PUB, 'render.js'));
12
+
13
+ const PORT = process.env.PORT || 7860;
14
+ const ROOT = process.env.DATA_DIR || '/tmp/mr';
15
+ const PREVIEW_EDGE = 1600;
16
+ const SESSION_TTL = 3 * 3600 * 1000;
17
+ fs.mkdirSync(ROOT, { recursive: true });
18
+ sharp.cache(false);
19
+
20
+ /* Windows font names used by the app -> Linux look-alikes (best effort) */
21
+ for (const [real, alias] of [
22
+ ['Liberation Sans', 'Arial'],
23
+ ['Liberation Mono', 'Courier New'],
24
+ ['DejaVu Serif', 'Georgia'],
25
+ ['DejaVu Sans', 'Impact'],
26
+ ['Noto Sans Sinhala', 'Iskoola Pota'],
27
+ ]) {
28
+ try { GlobalFonts.setAlias(real, alias); } catch {}
29
+ }
30
+
31
+ /* ---------- sessions (one per browser tab, id is random and unguessable) ---------- */
32
+ const sessions = new Map();
33
+ function getSess(sid) {
34
+ if (!/^[a-f0-9]{32}$/.test(sid || '')) throw Object.assign(new Error('bad session'), { code: 400 });
35
+ let s = sessions.get(sid);
36
+ if (!s) {
37
+ const dir = path.join(ROOT, sid);
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ s = { dir, items: new Map(), layers: [], t: Date.now() };
40
+ sessions.set(sid, s);
41
+ }
42
+ s.t = Date.now();
43
+ return s;
44
+ }
45
+ setInterval(() => {
46
+ const now = Date.now();
47
+ for (const [sid, s] of sessions) {
48
+ if (now - s.t > SESSION_TTL) {
49
+ sessions.delete(sid);
50
+ fs.rm(s.dir, { recursive: true, force: true }, () => {});
51
+ }
52
+ }
53
+ }, 10 * 60 * 1000).unref();
54
+
55
+ /* ---------- helpers ---------- */
56
+ const bearer = req => (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
57
+ async function pool(list, n, fn) {
58
+ let i = 0;
59
+ await Promise.all(Array.from({ length: Math.min(n, list.length) }, async () => {
60
+ while (i < list.length) await fn(list[i++]);
61
+ }));
62
+ }
63
+ function ndjson(res) {
64
+ res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
65
+ res.setHeader('Cache-Control', 'no-store');
66
+ res.setHeader('X-Accel-Buffering', 'no');
67
+ res.flushHeaders();
68
+ return o => { if (!res.writableEnded && !res.destroyed) res.write(JSON.stringify(o) + '\n'); };
69
+ }
70
+ const fail = (res, e) => res.status(e.code && e.code < 600 ? e.code : 500).send(e.message || 'error');
71
+
72
+ async function driveUpload(tok, folderId, name, mime, buf) {
73
+ let last;
74
+ for (let attempt = 0; attempt < 3; attempt++) {
75
+ try {
76
+ const init = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id', {
77
+ method: 'POST',
78
+ headers: {
79
+ Authorization: 'Bearer ' + tok,
80
+ 'Content-Type': 'application/json; charset=UTF-8',
81
+ 'X-Upload-Content-Type': mime,
82
+ 'X-Upload-Content-Length': String(buf.length),
83
+ },
84
+ body: JSON.stringify({ name, parents: [folderId] }),
85
+ });
86
+ if (init.status === 401) throw new Error('Google session expired');
87
+ if (!init.ok) throw new Error('Drive ' + init.status);
88
+ const put = await fetch(init.headers.get('location'), {
89
+ method: 'PUT',
90
+ headers: { 'Content-Type': mime, 'Content-Length': String(buf.length) },
91
+ body: buf,
92
+ });
93
+ if (!put.ok) throw new Error('Drive upload ' + put.status);
94
+ return;
95
+ } catch (e) {
96
+ last = e;
97
+ if (/expired/.test(e.message)) break;
98
+ await new Promise(r => setTimeout(r, 800 * (attempt + 1)));
99
+ }
100
+ }
101
+ throw last;
102
+ }
103
+
104
+ /* Same drawing code as the browser preview (public/render.js), at full resolution. */
105
+ async function renderBuf(sess, rid, ov, o) {
106
+ const it = sess.items.get(rid);
107
+ if (!it) throw Object.assign(new Error('unknown photo'), { code: 404 });
108
+ const k = o.max ? Math.min(1, o.max / Math.max(it.w, it.h)) : 1;
109
+ const w = Math.round(it.w * k), h = Math.round(it.h * k);
110
+ let pipe = sharp(it.file).rotate();
111
+ if (k < 1) pipe = pipe.resize(w, h, { fit: 'fill' });
112
+ const { data, info } = await pipe.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
113
+ const c = createCanvas(info.width, info.height);
114
+ const ctx = c.getContext('2d');
115
+ ctx.putImageData(new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.length), info.width, info.height), 0, 0);
116
+ R.drawAll(ctx, info.width, info.height, sess.layers, { ov: ov || {} });
117
+ const png = o.fmt === 'png';
118
+ const q = Math.round(Math.min(1, Math.max(0.5, +o.q || 0.92)) * 100);
119
+ return { buf: await c.encode(png ? 'png' : 'jpeg', q), mime: png ? 'image/png' : 'image/jpeg' };
120
+ }
121
+
122
+ /* ---------- app ---------- */
123
+ const app = express();
124
+ app.disable('x-powered-by');
125
+ if (PUB === __dirname) {
126
+ app.get('/', (q, r) => r.sendFile(path.join(PUB, 'index.html')));
127
+ app.get('/render.js', (q, r) => r.sendFile(path.join(PUB, 'render.js')));
128
+ } else {
129
+ app.use(express.static(PUB));
130
+ }
131
+
132
+ app.get('/api/config', (req, res) => res.json({ clientId: process.env.GOOGLE_CLIENT_ID || '' }));
133
+
134
+ /* Download picked Drive files on the server (parallel), stream progress back */
135
+ app.post('/api/import', express.json({ limit: '2mb' }), async (req, res) => {
136
+ let sess;
137
+ try { sess = getSess(req.body.sid); } catch (e) { return fail(res, e); }
138
+ const tok = bearer(req);
139
+ const files = Array.isArray(req.body.files) ? req.body.files.slice(0, 2000) : [];
140
+ if (!tok) return res.status(401).send('missing token');
141
+ const send = ndjson(res);
142
+ send({ t: 'start', n: files.length });
143
+ await pool(files, 6, async f => {
144
+ try {
145
+ const rid = crypto.randomBytes(6).toString('hex');
146
+ const file = path.join(sess.dir, rid + '.orig');
147
+ const r = await fetch(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(f.id)}?alt=media&supportsAllDrives=true`, {
148
+ headers: { Authorization: 'Bearer ' + tok },
149
+ });
150
+ if (r.status === 401) throw new Error('Google session expired, reconnect Drive');
151
+ if (!r.ok) throw new Error('Drive returned ' + r.status);
152
+ await pipeline(Readable.fromWeb(r.body), fs.createWriteStream(file));
153
+ const meta = await sharp(file).metadata();
154
+ const swap = (meta.orientation || 1) >= 5;
155
+ const w = swap ? meta.height : meta.width, h = swap ? meta.width : meta.height;
156
+ await sharp(file).rotate()
157
+ .resize({ width: PREVIEW_EDGE, height: PREVIEW_EDGE, fit: 'inside', withoutEnlargement: true })
158
+ .jpeg({ quality: 86 }).toFile(path.join(sess.dir, rid + '.prev.jpg'));
159
+ sess.items.set(rid, { name: String(f.name || rid), w, h, file });
160
+ send({ t: 'item', rid, name: String(f.name || rid), w, h });
161
+ } catch (e) {
162
+ send({ t: 'err', name: f.name, msg: e.message });
163
+ }
164
+ });
165
+ send({ t: 'done' });
166
+ res.end();
167
+ });
168
+
169
+ app.get('/api/preview/:sid/:rid', (req, res) => {
170
+ try {
171
+ const s = getSess(req.params.sid);
172
+ if (!s.items.has(req.params.rid)) return res.sendStatus(404);
173
+ res.setHeader('Cache-Control', 'private, max-age=3600');
174
+ res.sendFile(path.join(s.dir, req.params.rid + '.prev.jpg'));
175
+ } catch (e) { fail(res, e); }
176
+ });
177
+
178
+ /* Watermark layers (image layers arrive as PNG data URLs) */
179
+ app.post('/api/settings/:sid', express.json({ limit: '80mb' }), async (req, res) => {
180
+ try {
181
+ const s = getSess(req.params.sid);
182
+ const layers = Array.isArray(req.body.layers) ? req.body.layers : [];
183
+ s.layers = await Promise.all(layers.map(async l => {
184
+ const L = { ...l };
185
+ if (l.type === 'image') {
186
+ L.cv = await loadImage(Buffer.from(String(l.png).split(',')[1] || '', 'base64'));
187
+ delete L.png;
188
+ }
189
+ return L;
190
+ }));
191
+ res.json({ ok: true });
192
+ } catch (e) { fail(res, e); }
193
+ });
194
+
195
+ /* Render one photo at full size and send it back (for ZIP / separate downloads) */
196
+ app.post('/api/render/:sid/:rid', express.json({ limit: '1mb' }), async (req, res) => {
197
+ try {
198
+ const s = getSess(req.params.sid);
199
+ const { buf, mime } = await renderBuf(s, req.params.rid, req.body.ov, req.body);
200
+ res.type(mime).send(buf);
201
+ } catch (e) { fail(res, e); }
202
+ });
203
+
204
+ /* Render + upload straight into a Drive folder (created by the browser in the chosen account) */
205
+ app.post('/api/export-drive', express.json({ limit: '5mb' }), async (req, res) => {
206
+ let sess;
207
+ try { sess = getSess(req.body.sid); } catch (e) { return fail(res, e); }
208
+ const tok = bearer(req);
209
+ const { folderId, items } = req.body;
210
+ if (!tok || !folderId || !Array.isArray(items)) return res.status(400).send('bad request');
211
+ const send = ndjson(res);
212
+ await pool(items, 3, async it => {
213
+ try {
214
+ const { buf, mime } = await renderBuf(sess, it.rid, it.ov, req.body);
215
+ await driveUpload(tok, folderId, String(it.name), mime, buf);
216
+ send({ t: 'ok', name: it.name });
217
+ } catch (e) {
218
+ send({ t: 'err', name: it.name, msg: e.message });
219
+ }
220
+ });
221
+ send({ t: 'done' });
222
+ res.end();
223
+ });
224
+
225
+ app.listen(PORT, '0.0.0.0', () => console.log('markroom listening on ' + PORT));