Upload 4 files
Browse files- public/README.md +21 -0
- public/index.html +577 -0
- public/render.js +31 -0
- 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=>({'&':'&','<':'<','>':'>','"':'"'}[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));
|