689 lines
42 KiB
HTML
689 lines
42 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="vi">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Studio Ảnh Sản Phẩm · Tách nền & Ghép Frame</title>
|
||
<style>
|
||
:root {
|
||
--accent: #6366f1; --accent2: #8b5cf6; --accent-grad: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||
--bg: #f6f7fb; --card: #ffffff; --line: #e7e9f3; --line2: #eef0f7;
|
||
--text: #1b1f33; --muted: #71768f; --ok: #10b981; --danger: #ef4444; --warn: #f59e0b;
|
||
--radius: 16px; --shadow: 0 6px 24px rgba(31,38,90,.08); --shadow-lg: 0 16px 48px rgba(31,38,90,.16);
|
||
--checker: repeating-conic-gradient(#e9ecf5 0% 25%, #fff 0% 50%) 50% / 18px 18px;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--text);
|
||
background: radial-gradient(1200px 600px at 80% -10%, #eef0ff 0%, transparent 60%), var(--bg); }
|
||
h1, h2, h3 { margin: 0; }
|
||
button { font-family: inherit; }
|
||
|
||
/* Header */
|
||
header { position: sticky; top: 0; z-index: 20; backdrop-filter: saturate(1.4) blur(10px);
|
||
background: rgba(255,255,255,.78); border-bottom: 1px solid var(--line);
|
||
display: flex; align-items: center; gap: 18px; padding: 12px 24px; }
|
||
.logo { display: flex; align-items: center; gap: 11px; font-weight: 800; font-size: 16px; letter-spacing: -.01em; }
|
||
.logo .mark { width: 30px; height: 30px; border-radius: 9px; background: var(--accent-grad);
|
||
display: grid; place-items: center; color: #fff; font-size: 16px; box-shadow: 0 4px 12px rgba(99,102,241,.45); }
|
||
.logo small { display: block; font-weight: 500; font-size: 11px; color: var(--muted); letter-spacing: 0; }
|
||
.spacer { flex: 1; }
|
||
/* Segmented tabs */
|
||
.seg { display: inline-flex; background: #eef0f7; border: 1px solid var(--line); border-radius: 12px; padding: 4px; gap: 4px; }
|
||
.seg button { border: 0; background: transparent; color: var(--muted); font-weight: 600; font-size: 13.5px;
|
||
padding: 8px 16px; border-radius: 9px; cursor: pointer; display: flex; align-items: center; gap: 7px; transition: .18s; }
|
||
.seg button.active { background: #fff; color: var(--accent); box-shadow: var(--shadow); }
|
||
.seg button:not(.active):hover { color: var(--text); }
|
||
|
||
main { max-width: 1280px; margin: 0 auto; padding: 24px; }
|
||
.tab { display: none; }
|
||
.tab.active { display: block; animation: fade .25s ease; }
|
||
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||
|
||
/* Cards & layout */
|
||
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||
.card-pad { padding: 20px; }
|
||
.grid-remove { display: grid; grid-template-columns: 300px 1fr; gap: 20px; align-items: start; }
|
||
.grid-frame { display: grid; grid-template-columns: 240px 1fr 320px; gap: 20px; align-items: start; }
|
||
.sticky { position: sticky; top: 86px; }
|
||
.sec-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||
.sec-title .num { width: 20px; height: 20px; border-radius: 6px; background: var(--accent-grad); color: #fff; display: grid; place-items: center; font-size: 11px; }
|
||
|
||
/* Form controls */
|
||
label.field { display: block; font-size: 12.5px; font-weight: 600; margin: 0 0 7px; color: #3a3f57; }
|
||
select, input[type=number], input[type=text] { width: 100%; padding: 9px 11px; border: 1px solid var(--line); border-radius: 10px;
|
||
font-size: 13px; background: #fff; color: var(--text); transition: .15s; }
|
||
select:focus, input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(99,102,241,.14); }
|
||
input[type=range] { width: 100%; accent-color: var(--accent); }
|
||
.stack > * + * { margin-top: 15px; }
|
||
.val { color: var(--accent); font-weight: 700; }
|
||
.switch { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 13px; font-weight: 600; }
|
||
.switch input { width: 38px; height: 22px; appearance: none; background: #d4d7e6; border-radius: 999px; position: relative; cursor: pointer; transition: .2s; }
|
||
.switch input:checked { background: var(--accent); }
|
||
.switch input::after { content: ""; position: absolute; width: 18px; height: 18px; background: #fff; border-radius: 50%; top: 2px; left: 2px; transition: .2s; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
|
||
.switch input:checked::after { left: 18px; }
|
||
|
||
.btn { background: var(--accent-grad); color: #fff; border: 0; padding: 11px 18px; border-radius: 11px; font-size: 14px; font-weight: 700;
|
||
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px; box-shadow: 0 6px 16px rgba(99,102,241,.32); transition: .16s; }
|
||
.btn:hover { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(99,102,241,.4); }
|
||
.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; box-shadow: none; }
|
||
.btn.block { width: 100%; }
|
||
.btn-ghost { background: #fff; color: var(--text); border: 1px solid var(--line); box-shadow: none; }
|
||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); box-shadow: none; transform: none; }
|
||
.btn-sm { padding: 7px 12px; font-size: 12.5px; border-radius: 9px; }
|
||
.muted { color: var(--muted); font-size: 12.5px; }
|
||
|
||
/* Dropzone */
|
||
#drop { border: 2px dashed #cfd3e8; border-radius: var(--radius); padding: 40px 20px; text-align: center; cursor: pointer; transition: .18s; background: #fbfbff; }
|
||
#drop:hover, #drop.over { border-color: var(--accent); background: #f1f2ff; }
|
||
#drop .ic { font-size: 30px; }
|
||
#drop b { color: var(--accent); }
|
||
|
||
/* Result grid (tab 1) */
|
||
.results { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 16px; margin-top: 18px; }
|
||
.result { position: relative; border: 1px solid var(--line); border-radius: 14px; overflow: hidden; background: #fff; transition: .16s; }
|
||
.result:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
|
||
.result .thumb { width: 100%; aspect-ratio: 1; object-fit: contain; background: var(--checker); cursor: zoom-in; display: block; }
|
||
.badge-lvl { position: absolute; top: 9px; left: 9px; z-index: 2; font-size: 10.5px; font-weight: 800; letter-spacing: .04em; padding: 3px 9px; border-radius: 999px; color: #fff; text-transform: uppercase; box-shadow: 0 2px 6px rgba(0,0,0,.25); }
|
||
.badge-lvl.low { background: #94a3b8; } .badge-lvl.medium { background: var(--warn); } .badge-lvl.high { background: var(--ok); }
|
||
.result .acts { position: absolute; top: 8px; right: 8px; z-index: 2; display: flex; gap: 6px; opacity: 0; transition: .15s; }
|
||
.result:hover .acts { opacity: 1; }
|
||
.icon-btn { background: rgba(20,22,40,.82); color: #fff; border: 0; border-radius: 8px; padding: 5px 9px; font-size: 12px; font-weight: 600; cursor: pointer; backdrop-filter: blur(4px); }
|
||
.icon-btn:hover { background: var(--accent); }
|
||
.result .meta { padding: 9px 11px; font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 6px; border-top: 1px solid var(--line2); }
|
||
.result .meta a { color: var(--accent); font-weight: 700; text-decoration: none; }
|
||
.result.err { border-color: #fca5a5; } .result.err .meta { color: var(--danger); }
|
||
.saverow { display: flex; gap: 6px; padding: 9px 11px; border-top: 1px solid var(--line2); background: #fafbff; }
|
||
.saverow input { padding: 6px 9px; font-size: 12px; }
|
||
|
||
/* Thumbnail picker (assets / objects) */
|
||
.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); gap: 10px; }
|
||
.thumb-item { position: relative; aspect-ratio: 1; border: 2px solid var(--line); border-radius: 11px; overflow: hidden; cursor: pointer; background: var(--checker); transition: .14s; }
|
||
.thumb-item img { width: 100%; height: 100%; object-fit: contain; }
|
||
.thumb-item:hover { border-color: #c3c7e6; }
|
||
.thumb-item.sel { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(99,102,241,.2); }
|
||
.thumb-item.sel::after { content: "✓"; position: absolute; top: 3px; right: 4px; width: 16px; height: 16px; background: var(--accent); color: #fff; border-radius: 50%; font-size: 11px; display: grid; place-items: center; }
|
||
.thumb-item .del { position: absolute; bottom: 3px; right: 3px; background: rgba(239,68,68,.92); color: #fff; border: 0; border-radius: 6px; font-size: 11px; padding: 1px 6px; cursor: pointer; opacity: 0; transition: .14s; }
|
||
.thumb-item:hover .del { opacity: 1; }
|
||
.thumb-add { border: 2px dashed #cfd3e8; border-radius: 11px; aspect-ratio: 1; display: grid; place-items: center; cursor: pointer; color: var(--muted); font-size: 22px; background: #fbfbff; transition: .14s; }
|
||
.thumb-add:hover { border-color: var(--accent); color: var(--accent); }
|
||
.thumb-none { font-size: 12px; color: var(--muted); padding: 14px; text-align: center; border: 1px dashed var(--line); border-radius: 11px; }
|
||
|
||
/* Live preview (tab 2) */
|
||
.preview-wrap { position: relative; width: 100%; aspect-ratio: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); }
|
||
.preview-stage { position: absolute; inset: 0; display: grid; place-items: center; }
|
||
#pvObj { position: relative; z-index: 1; max-width: 100%; max-height: 100%; object-fit: contain; }
|
||
#pvFrame { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 2; pointer-events: none; }
|
||
#pvWm { position: absolute; z-index: 3; pointer-events: none; }
|
||
.pv-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 20px; }
|
||
|
||
.swatches { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.sw { width: 32px; height: 32px; border-radius: 9px; border: 2px solid var(--line); cursor: pointer; padding: 0; position: relative; transition: .14s; }
|
||
.sw:hover { transform: translateY(-1px); }
|
||
.sw.on { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(99,102,241,.22); }
|
||
.sw-custom { display: grid; place-items: center; overflow: hidden; background: conic-gradient(#ef4444,#f59e0b,#eab308,#22c55e,#06b6d4,#6366f1,#a855f7,#ef4444); }
|
||
.sw-custom input { opacity: 0; width: 100%; height: 100%; cursor: pointer; }
|
||
.chips { display: flex; gap: 7px; flex-wrap: wrap; }
|
||
.chip { border: 1px solid var(--line); background: #fff; border-radius: 9px; padding: 7px 11px; font-size: 12.5px; font-weight: 600; cursor: pointer; color: var(--muted); transition: .14s; }
|
||
.chip.on { border-color: var(--accent); background: #f1f2ff; color: var(--accent); }
|
||
|
||
/* Toast */
|
||
#toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px); z-index: 80;
|
||
background: #16182b; color: #fff; padding: 12px 18px; border-radius: 12px; font-size: 13.5px; font-weight: 600;
|
||
box-shadow: var(--shadow-lg); opacity: 0; pointer-events: none; transition: .25s; display: flex; align-items: center; gap: 9px; }
|
||
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||
#toast.ok { border-left: 3px solid var(--ok); } #toast.err { border-left: 3px solid var(--danger); }
|
||
|
||
.spinner { width: 15px; height: 15px; border: 2px solid rgba(255,255,255,.5); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* Lightbox & crop editor */
|
||
.lb { position: fixed; inset: 0; z-index: 60; background: rgba(12,14,28,.95); display: flex; flex-direction: column; }
|
||
.lb[hidden] { display: none; }
|
||
.lb-bar { display: flex; align-items: center; gap: 10px; padding: 13px 18px; color: #fff; }
|
||
.lb-bar button, .lb-bar a { background: rgba(255,255,255,.13); color: #fff; border: 0; border-radius: 9px; padding: 8px 13px; font-size: 13.5px; font-weight: 600; cursor: pointer; text-decoration: none; }
|
||
.lb-bar button:hover, .lb-bar a:hover { background: rgba(255,255,255,.26); }
|
||
.lb-bar .which { font-weight: 700; min-width: 96px; text-align: center; } .lb-bar #lbToggle { background: var(--accent); }
|
||
.lb-bar .sp { flex: 1; } #lbZoom { min-width: 52px; text-align: center; font-variant-numeric: tabular-nums; }
|
||
.lb-stage { flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center; cursor: grab; }
|
||
.lb-stage.grabbing { cursor: grabbing; }
|
||
.lb-stage img { max-width: 92%; max-height: 100%; transform-origin: center; user-select: none; -webkit-user-drag: none; background: var(--checker); }
|
||
.lb-hint { color: rgba(255,255,255,.62); font-size: 12px; padding: 0 18px 13px; }
|
||
#cropImg { display: block; max-width: 86vw; max-height: 74vh; }
|
||
#polyShape { cursor: copy; } #polyHandles circle { fill: #818cf8; stroke: #fff; stroke-width: 2; cursor: grab; } #polyHandles circle:hover { fill: var(--warn); }
|
||
|
||
/* ===== Responsive: tablet ===== */
|
||
@media (max-width: 1024px) {
|
||
.grid-frame { grid-template-columns: 1fr; }
|
||
.grid-frame .sticky { position: static; }
|
||
.preview-wrap { max-width: 520px; margin: 0 auto; }
|
||
}
|
||
/* ===== Responsive: mobile ===== */
|
||
@media (max-width: 760px) {
|
||
header { padding: 10px 14px; gap: 10px; flex-wrap: wrap; }
|
||
.logo small { display: none; }
|
||
.spacer { display: none; }
|
||
.seg { width: 100%; justify-content: center; }
|
||
.seg button { flex: 1; justify-content: center; padding: 9px 10px; }
|
||
main { padding: 14px; }
|
||
.grid-remove { grid-template-columns: 1fr; }
|
||
.grid-remove .sticky { position: static; }
|
||
.card-pad { padding: 16px; }
|
||
.results { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
||
.result .acts { opacity: 1; } /* không hover trên touch → hiện sẵn */
|
||
#drop { padding: 30px 14px; }
|
||
.lb-bar { flex-wrap: wrap; gap: 8px; padding: 11px 14px; }
|
||
.lb-bar .which { min-width: auto; }
|
||
.lb-bar button, .lb-bar a { padding: 8px 11px; font-size: 13px; }
|
||
.lb-hint { padding: 0 14px 12px; }
|
||
.thumbs { grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); }
|
||
#toast { left: 14px; right: 14px; transform: translateY(20px); width: auto; }
|
||
#toast.show { transform: translateY(0); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="logo"><span class="mark">◈</span><div>Product Studio<small>Tách nền · Ghép frame cho ảnh ecom</small></div></div>
|
||
<div class="spacer"></div>
|
||
<div class="seg" id="tabs">
|
||
<button data-tab="remove" class="active">✂️ Tách nền</button>
|
||
<button data-tab="frame">🖼️ Ghép Frame</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<!-- ========================= TAB 1: TÁCH NỀN ========================= -->
|
||
<section class="tab active" id="tab-remove">
|
||
<div class="grid-remove">
|
||
<!-- Cấu hình -->
|
||
<div class="card card-pad sticky">
|
||
<div class="sec-title"><span class="num">1</span> Chất lượng tách nền</div>
|
||
<div class="stack">
|
||
<div>
|
||
<label class="field">Model AI</label>
|
||
<select id="model">
|
||
<option value="u2net">u2net · nhanh, nhẹ</option>
|
||
<option value="isnet-general-use">isnet · giữ mép tốt</option>
|
||
<option value="birefnet-general-lite">birefnet · chi tiết cao</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="field">Phục hồi mép · <span class="val" id="recVal">2px</span></label>
|
||
<input type="range" id="recover" min="0" max="6" value="2" />
|
||
</div>
|
||
<div class="switch"><span>Chống cháy sáng</span><input type="checkbox" id="antiBlowout" checked /></div>
|
||
<div class="switch"><span>So sánh 3 mức (low/med/high)</span><input type="checkbox" id="compare" /></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Upload + kết quả -->
|
||
<div>
|
||
<div class="card card-pad">
|
||
<div class="sec-title"><span class="num">2</span> Tải ảnh sản phẩm</div>
|
||
<div id="drop"><div class="ic">📦</div><p style="margin:8px 0 2px">Kéo thả ảnh vào đây — hoặc <b>bấm để chọn</b></p><span class="muted">Hỗ trợ nhiều ảnh cùng lúc · JPG / PNG / WebP</span></div>
|
||
<input type="file" id="imgInput" accept="image/*" multiple hidden />
|
||
<button class="btn btn-ghost" id="camBtn" style="margin-top:12px; width:100%">📷 Chụp ảnh trực tiếp</button>
|
||
<div id="fileList" class="muted" style="margin-top:10px"></div>
|
||
<div style="margin-top:16px; display:flex; gap:12px; align-items:center">
|
||
<button class="btn" id="runBtn" disabled>✨ Tách nền</button>
|
||
<span class="muted" id="status"></span>
|
||
</div>
|
||
</div>
|
||
<div class="results" id="results"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ========================= TAB 2: GHÉP FRAME ========================= -->
|
||
<section class="tab" id="tab-frame">
|
||
<div class="grid-frame">
|
||
<!-- Thư viện object -->
|
||
<div class="card card-pad sticky">
|
||
<div class="sec-title"><span class="num">1</span> Object đã tách nền</div>
|
||
<div class="thumbs" id="objLib"></div>
|
||
<p class="muted" id="objEmpty" style="margin-top:10px">Chưa có object. Sang tab <b>Tách nền</b>, xử lý rồi bấm <b>Lưu</b>.</p>
|
||
</div>
|
||
|
||
<!-- Preview -->
|
||
<div class="card card-pad">
|
||
<div class="sec-title"><span class="num">2</span> Xem trước (đổi ngay khi chọn frame / watermark)</div>
|
||
<div class="preview-wrap" id="pvWrap" style="background:var(--checker)">
|
||
<div class="preview-stage" id="pvStage">
|
||
<div class="pv-empty" id="pvEmpty">Chọn 1 object bên trái để bắt đầu.</div>
|
||
<img id="pvObj" hidden /><img id="pvFrame" hidden /><img id="pvWm" hidden />
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:10px; margin-top:16px">
|
||
<button class="btn block" id="saveBtn" disabled>💾 Lưu ảnh ghép</button>
|
||
</div>
|
||
<div class="results" id="composed" style="margin-top:16px"></div>
|
||
</div>
|
||
|
||
<!-- Cấu hình frame/watermark -->
|
||
<div class="card card-pad sticky stack">
|
||
<div>
|
||
<div class="sec-title"><span class="num">3</span> Frame <span class="muted" style="text-transform:none;letter-spacing:0">(chọn để đổi)</span></div>
|
||
<div class="thumbs" id="frameLib"></div>
|
||
</div>
|
||
<div>
|
||
<div class="sec-title">Watermark</div>
|
||
<div class="thumbs" id="wmLib"></div>
|
||
</div>
|
||
<div>
|
||
<label class="field">Vị trí watermark</label>
|
||
<select id="wmPos">
|
||
<option value="southeast">Dưới phải</option><option value="southwest">Dưới trái</option>
|
||
<option value="northeast">Trên phải</option><option value="northwest">Trên trái</option>
|
||
<option value="center">Giữa</option><option value="south">Dưới giữa</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="field">Độ mờ watermark · <span class="val" id="opVal">60%</span></label>
|
||
<input type="range" id="wmOpacity" min="0" max="100" value="60" />
|
||
</div>
|
||
<div>
|
||
<label class="field">Cỡ watermark · <span class="val" id="scVal">25%</span></label>
|
||
<input type="range" id="wmScale" min="5" max="60" value="25" />
|
||
</div>
|
||
<div>
|
||
<label class="field">Tỉ lệ object trong khung · <span class="val" id="objVal">100%</span></label>
|
||
<input type="range" id="objScale" min="40" max="100" value="100" />
|
||
</div>
|
||
<div>
|
||
<label class="field">Nền</label>
|
||
<div class="chips" style="margin-bottom:9px"><span class="chip on" data-bg="transparent">⬚ Trong suốt</span></div>
|
||
<div class="swatches" id="swatches">
|
||
<button class="sw" data-bg="#ffffff" style="background:#fff"></button>
|
||
<button class="sw" data-bg="#000000" style="background:#000"></button>
|
||
<button class="sw" data-bg="#f5f5f7" style="background:#f5f5f7"></button>
|
||
<button class="sw" data-bg="#f4ece1" style="background:#f4ece1"></button>
|
||
<button class="sw" data-bg="#fde2e4" style="background:#fde2e4"></button>
|
||
<button class="sw" data-bg="#e2f0cb" style="background:#e2f0cb"></button>
|
||
<button class="sw" data-bg="#cde7ff" style="background:#cde7ff"></button>
|
||
<button class="sw" data-bg="#6366f1" style="background:#6366f1"></button>
|
||
<label class="sw sw-custom" title="Màu tùy chọn"><input type="color" id="bgCustom" value="#ff6b6b" /></label>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="field">Viền · <span class="val" id="bdVal">0px</span></label>
|
||
<input type="range" id="border" min="0" max="200" value="0" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<!-- Lightbox -->
|
||
<div class="lb" id="lightbox" hidden>
|
||
<div class="lb-bar">
|
||
<button id="lbToggle">Xem ảnh gốc</button><span class="which" id="lbWhich">Đã xử lý</span>
|
||
<span class="sp"></span>
|
||
<button id="lbOut">−</button><span id="lbZoom">100%</span><button id="lbIn">+</button>
|
||
<button id="lbReset">Reset</button><a id="lbDownload" download>Tải</a><button id="lbClose">✕ Đóng</button>
|
||
</div>
|
||
<div class="lb-stage" id="lbStage"><img id="lbImg" alt="review" /></div>
|
||
<div class="lb-hint">Lăn chuột để zoom · kéo để di chuyển · nút "Xem ảnh gốc" để so sánh · double-click zoom nhanh · ESC để đóng</div>
|
||
</div>
|
||
|
||
<!-- Crop editor -->
|
||
<div class="lb" id="cropEditor" hidden>
|
||
<div class="lb-bar">
|
||
<b>Chọn vùng object (đa điểm)</b><span class="sp"></span>
|
||
<button id="cropAuto">Ôm sát object</button><button id="cropFull">Toàn ảnh</button>
|
||
<button id="cropApply" style="background:var(--accent)">Render lại vùng chọn</button><button id="cropCancel">✕ Đóng</button>
|
||
</div>
|
||
<div class="lb-stage" style="cursor:default; align-items:center">
|
||
<div id="cropWrap" style="position:relative; display:inline-block; line-height:0">
|
||
<img id="cropImg" alt="crop" />
|
||
<svg id="polySvg" style="position:absolute; left:0; top:0; width:100%; height:100%; overflow:visible">
|
||
<path id="polyDim" fill-rule="evenodd" fill="rgba(8,10,25,.6)" pointer-events="none"></path>
|
||
<polygon id="polyShape" fill="rgba(129,140,248,.12)" stroke="#818cf8" stroke-width="2"></polygon>
|
||
<g id="polyHandles"></g>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<div class="lb-hint" id="cropStatus">Kéo điểm để chỉnh · double-click lên cạnh để thêm điểm · chuột phải lên điểm để xóa.</div>
|
||
</div>
|
||
|
||
<!-- Camera -->
|
||
<div class="lb" id="camera" hidden>
|
||
<div class="lb-bar">
|
||
<b>📷 Chụp ảnh sản phẩm</b><span class="sp"></span>
|
||
<button id="camSwitch">🔄 Đổi camera</button><button id="camCancel">✕ Đóng</button>
|
||
</div>
|
||
<div class="lb-stage" style="cursor:default; flex-direction:column; gap:18px; padding:16px">
|
||
<video id="camVideo" autoplay playsinline muted style="max-width:94%; max-height:64vh; border-radius:16px; background:#000"></video>
|
||
<button class="btn" id="camShot" style="font-size:16px; padding:14px 30px; border-radius:999px">⬤ Chụp</button>
|
||
<div class="muted" id="camMsg" style="color:rgba(255,255,255,.72)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const api = (u, o) => fetch(u, o).then((r) => r.json());
|
||
function toast(msg, kind = "ok") {
|
||
const t = $("toast"); t.textContent = msg; t.className = "show " + kind;
|
||
clearTimeout(t._t); t._t = setTimeout(() => (t.className = ""), 2600);
|
||
}
|
||
|
||
// ===================== TABS =====================
|
||
$("tabs").addEventListener("click", (e) => {
|
||
const b = e.target.closest("button[data-tab]"); if (!b) return;
|
||
document.querySelectorAll("#tabs button").forEach((x) => x.classList.toggle("active", x === b));
|
||
document.querySelectorAll(".tab").forEach((s) => s.classList.toggle("active", s.id === "tab-" + b.dataset.tab));
|
||
if (b.dataset.tab === "frame") { loadObjects(); loadAssets(); }
|
||
});
|
||
|
||
// ===================== TAB 1: TÁCH NỀN =====================
|
||
let chosen = [];
|
||
$("recover").oninput = (e) => ($("recVal").textContent = e.target.value + "px");
|
||
|
||
const drop = $("drop");
|
||
drop.onclick = () => $("imgInput").click();
|
||
drop.ondragover = (e) => { e.preventDefault(); drop.classList.add("over"); };
|
||
drop.ondragleave = () => drop.classList.remove("over");
|
||
drop.ondrop = (e) => { e.preventDefault(); drop.classList.remove("over"); addFiles(e.dataTransfer.files); };
|
||
$("imgInput").onchange = (e) => addFiles(e.target.files);
|
||
function addFiles(list) {
|
||
chosen = chosen.concat([...list].filter((f) => f.type.startsWith("image/")));
|
||
$("fileList").textContent = chosen.length ? `Đã chọn ${chosen.length} ảnh: ` + chosen.map((f) => f.name).join(", ") : "";
|
||
$("runBtn").disabled = chosen.length === 0;
|
||
}
|
||
|
||
// --- Chụp ảnh trực tiếp ---
|
||
let camStream = null, camFacing = "environment";
|
||
function stopCam() { if (camStream) { camStream.getTracks().forEach((t) => t.stop()); camStream = null; } }
|
||
function fallbackCapture() { // mobile qua HTTP / không có getUserMedia → camera gốc của máy
|
||
const inp = Object.assign(document.createElement("input"), { type: "file", accept: "image/*" });
|
||
inp.setAttribute("capture", "environment");
|
||
inp.onchange = () => addFiles(inp.files);
|
||
inp.click();
|
||
}
|
||
async function startCam() {
|
||
stopCam();
|
||
camStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: camFacing }, audio: false });
|
||
$("camVideo").srcObject = camStream;
|
||
}
|
||
$("camBtn").onclick = async () => {
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { fallbackCapture(); return; }
|
||
$("camMsg").textContent = ""; $("camera").hidden = false;
|
||
try { await startCam(); }
|
||
catch (e) { $("camera").hidden = true; toast("Không mở được camera, dùng camera máy.", "err"); fallbackCapture(); }
|
||
};
|
||
$("camSwitch").onclick = async () => { camFacing = camFacing === "environment" ? "user" : "environment"; try { await startCam(); } catch (e) {} };
|
||
$("camCancel").onclick = () => { stopCam(); $("camera").hidden = true; };
|
||
$("camShot").onclick = () => {
|
||
const v = $("camVideo"); if (!v.videoWidth) return;
|
||
const cv = document.createElement("canvas"); cv.width = v.videoWidth; cv.height = v.videoHeight;
|
||
cv.getContext("2d").drawImage(v, 0, 0);
|
||
cv.toBlob((blob) => {
|
||
addFiles([new File([blob], `chup-${Date.now()}.jpg`, { type: "image/jpeg" })]);
|
||
$("camMsg").textContent = `Đã chụp ${chosen.length} ảnh — bấm Chụp tiếp hoặc Đóng để xử lý.`;
|
||
}, "image/jpeg", 0.95);
|
||
};
|
||
|
||
function tab1FD() {
|
||
const fd = new FormData();
|
||
fd.append("model", $("model").value);
|
||
fd.append("anti_blowout", $("antiBlowout").checked);
|
||
fd.append("recover", $("recover").value);
|
||
return fd;
|
||
}
|
||
|
||
$("runBtn").onclick = async () => {
|
||
const btn = $("runBtn"); btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Đang xử lý…';
|
||
$("status").textContent = ""; $("results").innerHTML = "";
|
||
const fd = tab1FD();
|
||
fd.append("compare", $("compare").checked);
|
||
chosen.forEach((f) => fd.append("images", f));
|
||
try {
|
||
const data = await api("/api/process", { method: "POST", body: fd });
|
||
if (data.error) throw new Error(data.error);
|
||
renderResults(data.results);
|
||
const ok = data.results.filter((x) => x.ok).length, reused = data.results.filter((x) => x.cached).length;
|
||
$("status").textContent = `Xong: ${ok}/${data.results.length}` + (reused ? ` · ⚡ ${reused} tái dùng` : "") + ".";
|
||
} catch (e) { $("status").textContent = "Lỗi: " + e.message; }
|
||
finally { btn.disabled = false; btn.textContent = "✨ Tách nền"; }
|
||
};
|
||
|
||
function renderResults(results) {
|
||
$("results").innerHTML = results.map((r, i) => r.ok
|
||
? `<div class="result" data-i="${i}" data-original="${r.original || ""}" data-level="${r.level || ""}" data-poly='${JSON.stringify(r.polygon || [[0,0],[1,0],[1,1],[0,1]])}'>
|
||
${r.level ? `<span class="badge-lvl ${r.level}">${r.level}</span>` : ""}
|
||
<div class="acts">
|
||
${r.original ? '<button class="icon-btn act-crop">✎ Vùng</button>' : ""}
|
||
<button class="icon-btn act-save">💾 Lưu</button>
|
||
</div>
|
||
<img class="thumb" src="/output/${r.output}" data-proc="/output/${r.output}" data-orig="${r.original ? "/output/" + r.original : ""}" />
|
||
<div class="meta"><span>${r.cached ? "⚡ " : ""}${trim(r.name)}</span><a href="/output/${r.output}" download>Tải</a></div>
|
||
<div class="saverow" hidden>
|
||
<input type="text" placeholder="Tên object…" value="${objName(r.name)}" />
|
||
<button class="btn btn-sm act-confirm">Lưu</button>
|
||
</div>
|
||
</div>`
|
||
: `<div class="result err"><div class="meta">${trim(r.name)}${r.level ? " · " + r.level : ""} — ${r.error}</div></div>`
|
||
).join("");
|
||
}
|
||
const trim = (s) => (s && s.length > 20 ? s.slice(0, 17) + "…" : s || "");
|
||
const objName = (s) => (s || "object").replace(/\.[^.]+$/, "").slice(0, 30);
|
||
|
||
// Hành động trên thẻ kết quả (delegation)
|
||
$("results").addEventListener("click", async (e) => {
|
||
const card = e.target.closest(".result"); if (!card) return;
|
||
if (e.target.closest(".act-crop")) { openCrop(card); return; }
|
||
if (e.target.closest(".act-save")) { card.querySelector(".saverow").hidden = false; return; }
|
||
if (e.target.closest(".act-confirm")) {
|
||
const name = card.querySelector(".saverow input").value.trim();
|
||
const out = card.querySelector("img").dataset.proc.split("/").pop();
|
||
const fd = new FormData(); fd.append("output", out); fd.append("name", name);
|
||
const d = await api("/api/save-object", { method: "POST", body: fd });
|
||
if (d.ok) { toast(`Đã lưu object “${d.name}”`); card.querySelector(".saverow").hidden = true; }
|
||
else toast(d.error || "Lỗi lưu", "err");
|
||
return;
|
||
}
|
||
const img = e.target.closest(".thumb"); if (img) openLightbox(img);
|
||
});
|
||
|
||
// ===================== LIGHTBOX =====================
|
||
const lbImg = $("lbImg"), lbStage = $("lbStage");
|
||
let lbScale = 1, lbX = 0, lbY = 0, lbProc = "", lbOrig = "", lbShowOrig = false;
|
||
function lbApply() { lbImg.style.transform = `translate(${lbX}px,${lbY}px) scale(${lbScale})`; $("lbZoom").textContent = Math.round(lbScale * 100) + "%"; }
|
||
function lbReset() { lbScale = 1; lbX = 0; lbY = 0; lbApply(); }
|
||
function lbSet() {
|
||
const src = lbShowOrig && lbOrig ? lbOrig : lbProc; lbImg.src = src;
|
||
$("lbWhich").textContent = lbShowOrig ? "Ảnh gốc" : "Đã xử lý";
|
||
$("lbToggle").textContent = lbShowOrig ? "Xem đã xử lý" : "Xem ảnh gốc";
|
||
$("lbToggle").style.display = lbOrig ? "" : "none"; $("lbDownload").href = src;
|
||
}
|
||
function lbZoom(f) { lbScale = Math.min(8, Math.max(0.2, lbScale * f)); lbApply(); }
|
||
function openLightbox(img) { lbProc = img.dataset.proc; lbOrig = img.dataset.orig || ""; lbShowOrig = false; lbSet(); lbReset(); $("lightbox").hidden = false; }
|
||
$("lbToggle").onclick = () => { lbShowOrig = !lbShowOrig; lbSet(); };
|
||
$("lbIn").onclick = () => lbZoom(1.25); $("lbOut").onclick = () => lbZoom(0.8); $("lbReset").onclick = lbReset;
|
||
$("lbClose").onclick = () => { $("lightbox").hidden = true; lbImg.src = ""; };
|
||
lbStage.addEventListener("wheel", (e) => { e.preventDefault(); lbZoom(e.deltaY < 0 ? 1.12 : 0.89); }, { passive: false });
|
||
lbStage.addEventListener("dblclick", () => (lbScale > 1.05 ? lbReset() : lbZoom(2.2)));
|
||
let lbDrag = false, ldx = 0, ldy = 0;
|
||
lbStage.addEventListener("mousedown", (e) => { lbDrag = true; ldx = e.clientX - lbX; ldy = e.clientY - lbY; lbStage.classList.add("grabbing"); });
|
||
window.addEventListener("mousemove", (e) => { if (!lbDrag) return; lbX = e.clientX - ldx; lbY = e.clientY - ldy; lbApply(); });
|
||
window.addEventListener("mouseup", () => { lbDrag = false; lbStage.classList.remove("grabbing"); });
|
||
window.addEventListener("keydown", (e) => {
|
||
if (!$("lightbox").hidden) { if (e.key === "Escape") $("lbClose").onclick(); else if (e.key === "+" || e.key === "=") lbZoom(1.25); else if (e.key === "-") lbZoom(0.8); else if (e.key.toLowerCase() === "g" && lbOrig) $("lbToggle").onclick(); }
|
||
if (!$("cropEditor").hidden && e.key === "Escape") $("cropCancel").onclick();
|
||
});
|
||
|
||
// ===================== CROP EDITOR (polygon) =====================
|
||
const RECT_POLY = [[0,0],[1,0],[1,1],[0,1]];
|
||
let cropCard = null, cropLevel = "", cropDefault = RECT_POLY, poly = [], dragIdx = -1;
|
||
const cwRect = () => $("cropWrap").getBoundingClientRect();
|
||
function toNorm(e) { const r = cwRect(); return [Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)), Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))]; }
|
||
function segDist(p, a, b) { const dx = b[0]-a[0], dy = b[1]-a[1], L = dx*dx+dy*dy||1e-9; let t = ((p[0]-a[0])*dx+(p[1]-a[1])*dy)/L; t = Math.max(0, Math.min(1, t)); return Math.hypot(p[0]-(a[0]+t*dx), p[1]-(a[1]+t*dy)); }
|
||
function drawPoly() {
|
||
const r = cwRect(), W = r.width, H = r.height; if (!W || !H) return;
|
||
const px = poly.map(([x, y]) => [x * W, y * H]);
|
||
const svg = $("polySvg"); svg.setAttribute("width", W); svg.setAttribute("height", H);
|
||
$("polyShape").setAttribute("points", px.map((p) => p.join(",")).join(" "));
|
||
$("polyDim").setAttribute("d", `M0,0 H${W} V${H} H0 Z M` + px.map((p) => p.join(",")).join(" L") + " Z");
|
||
$("polyHandles").innerHTML = px.map(([x, y], i) => `<circle cx="${x}" cy="${y}" r="7" data-i="${i}"></circle>`).join("");
|
||
}
|
||
function openCrop(card) {
|
||
cropCard = card; const orig = card.querySelector("img").dataset.orig; if (!orig) return;
|
||
cropLevel = card.dataset.level || "";
|
||
try { cropDefault = JSON.parse(card.dataset.poly); } catch (e) { cropDefault = RECT_POLY; }
|
||
poly = cropDefault.map((p) => p.slice());
|
||
const im = $("cropImg"); im.onload = drawPoly; im.src = orig;
|
||
$("cropEditor").hidden = false; if (im.complete) drawPoly();
|
||
}
|
||
$("polyHandles").addEventListener("mousedown", (e) => { const c = e.target.closest("circle"); if (!c) return; e.preventDefault(); e.stopPropagation(); dragIdx = +c.dataset.i; });
|
||
window.addEventListener("mousemove", (e) => { if (dragIdx < 0) return; poly[dragIdx] = toNorm(e); drawPoly(); });
|
||
window.addEventListener("mouseup", () => (dragIdx = -1));
|
||
$("polyHandles").addEventListener("contextmenu", (e) => { const c = e.target.closest("circle"); if (!c) return; e.preventDefault(); if (poly.length <= 3) return; poly.splice(+c.dataset.i, 1); drawPoly(); });
|
||
$("polySvg").addEventListener("dblclick", (e) => {
|
||
if (e.target.closest("circle")) return; const p = toNorm(e); let best = 0, bd = Infinity;
|
||
for (let i = 0; i < poly.length; i++) { const d = segDist(p, poly[i], poly[(i + 1) % poly.length]); if (d < bd) { bd = d; best = i; } }
|
||
poly.splice(best + 1, 0, p); drawPoly();
|
||
});
|
||
$("cropAuto").onclick = () => { poly = cropDefault.map((p) => p.slice()); drawPoly(); };
|
||
$("cropFull").onclick = () => { poly = RECT_POLY.map((p) => p.slice()); drawPoly(); };
|
||
$("cropCancel").onclick = () => ($("cropEditor").hidden = true);
|
||
window.addEventListener("resize", () => { if (!$("cropEditor").hidden) drawPoly(); });
|
||
$("cropApply").onclick = async () => {
|
||
const btn = $("cropApply"); btn.disabled = true; btn.textContent = "Đang render…";
|
||
const fd = tab1FD(); fd.append("original", cropCard.dataset.original); fd.append("level", cropLevel); fd.append("poly", JSON.stringify(poly));
|
||
try {
|
||
const d = await api("/api/recrop", { method: "POST", body: fd });
|
||
if (!d.ok) throw new Error(d.error || "lỗi");
|
||
const url = "/output/" + d.output, img = cropCard.querySelector("img");
|
||
img.src = url + "?t=" + Date.now(); img.dataset.proc = url;
|
||
const dl = cropCard.querySelector("a[download]"); if (dl) dl.href = url;
|
||
$("cropEditor").hidden = true; toast("Đã render lại vùng chọn");
|
||
} catch (err) { toast("Lỗi: " + err.message, "err"); }
|
||
finally { btn.disabled = false; btn.textContent = "Render lại vùng chọn"; }
|
||
};
|
||
|
||
// ===================== TAB 2: GHÉP FRAME =====================
|
||
let activeObj = null, selFrame = null, selWm = null, curBg = "transparent";
|
||
$("wmOpacity").oninput = (e) => { $("opVal").textContent = e.target.value + "%"; renderPreview(); };
|
||
$("wmScale").oninput = (e) => { $("scVal").textContent = e.target.value + "%"; renderPreview(); };
|
||
$("objScale").oninput = (e) => { $("objVal").textContent = e.target.value + "%"; renderPreview(); };
|
||
$("border").oninput = (e) => { $("bdVal").textContent = e.target.value + "px"; renderPreview(); };
|
||
$("wmPos").onchange = renderPreview;
|
||
function selectBg(value, el) {
|
||
curBg = value;
|
||
document.querySelectorAll("[data-bg], .sw-custom").forEach((x) => x.classList.remove("on"));
|
||
if (el) el.classList.add("on");
|
||
$("pvWrap").style.background = value === "transparent" ? "var(--checker)" : value;
|
||
renderPreview();
|
||
}
|
||
document.querySelectorAll("[data-bg]").forEach((el) => (el.onclick = () => selectBg(el.dataset.bg, el)));
|
||
$("bgCustom").oninput = (e) => selectBg(e.target.value, e.target.closest(".sw"));
|
||
|
||
async function loadObjects() {
|
||
const d = await api("/api/objects");
|
||
$("objEmpty").style.display = d.items.length ? "none" : "";
|
||
$("objLib").innerHTML = d.items.map((o) =>
|
||
`<div class="thumb-item ${activeObj === o.name ? "sel" : ""}" data-name="${o.name}" title="${o.name}">
|
||
<img src="${o.url}" /><button class="del" data-del-obj="${o.name}">✕</button></div>`).join("");
|
||
}
|
||
$("objLib").addEventListener("click", async (e) => {
|
||
const del = e.target.closest("[data-del-obj]");
|
||
if (del) { await fetch("/api/objects/" + del.dataset.delObj, { method: "DELETE" }); if (activeObj === del.dataset.delObj) activeObj = null; loadObjects(); renderPreview(); return; }
|
||
const it = e.target.closest(".thumb-item"); if (!it) return;
|
||
activeObj = it.dataset.name;
|
||
document.querySelectorAll("#objLib .thumb-item").forEach((x) => x.classList.toggle("sel", x === it));
|
||
renderPreview();
|
||
});
|
||
|
||
async function loadAssets() {
|
||
for (const kind of ["frame", "watermark"]) {
|
||
const d = await api("/api/asset/" + kind);
|
||
const lib = kind === "frame" ? "frameLib" : "wmLib";
|
||
const sel = kind === "frame" ? selFrame : selWm;
|
||
$(lib).innerHTML =
|
||
d.items.map((a) => `<div class="thumb-item ${sel === a.id ? "sel" : ""}" data-kind="${kind}" data-id="${a.id}"><img src="${a.url}" /><button class="del" data-del-asset="${a.id}" data-k="${kind}">✕</button></div>`).join("") +
|
||
`<div class="thumb-add" data-add="${kind}">+</div>`;
|
||
}
|
||
}
|
||
document.addEventListener("click", async (e) => {
|
||
const add = e.target.closest("[data-add]");
|
||
if (add) {
|
||
const inp = Object.assign(document.createElement("input"), { type: "file", accept: "image/*" });
|
||
inp.onchange = async () => {
|
||
const fd = new FormData(); fd.append("kind", add.dataset.add); fd.append("file", inp.files[0]);
|
||
const d = await api("/api/asset", { method: "POST", body: fd });
|
||
if (d.ok) { toast("Đã thêm " + add.dataset.add); loadAssets(); }
|
||
};
|
||
inp.click(); return;
|
||
}
|
||
const del = e.target.closest("[data-del-asset]");
|
||
if (del) {
|
||
e.stopPropagation();
|
||
await fetch(`/api/asset/${del.dataset.k}/${del.dataset.delAsset}`, { method: "DELETE" });
|
||
if (del.dataset.k === "frame" && selFrame === del.dataset.delAsset) selFrame = null;
|
||
if (del.dataset.k === "watermark" && selWm === del.dataset.delAsset) selWm = null;
|
||
loadAssets(); renderPreview(); return;
|
||
}
|
||
const it = e.target.closest("#frameLib .thumb-item, #wmLib .thumb-item");
|
||
if (it) {
|
||
const kind = it.dataset.kind, id = it.dataset.id;
|
||
if (kind === "frame") selFrame = selFrame === id ? null : id;
|
||
else selWm = selWm === id ? null : id;
|
||
it.parentElement.querySelectorAll(".thumb-item").forEach((x) => x.classList.toggle("sel", x === it && (kind === "frame" ? selFrame : selWm)));
|
||
renderPreview();
|
||
}
|
||
});
|
||
|
||
function assetUrl(kind, id) { return `/api/asset/${kind}/${id}`; }
|
||
function renderPreview() {
|
||
const has = !!activeObj;
|
||
$("pvEmpty").style.display = has ? "none" : "";
|
||
$("saveBtn").disabled = !has;
|
||
["pvObj", "pvFrame", "pvWm"].forEach((k) => ($(k).hidden = !has));
|
||
if (!has) return;
|
||
const obj = $("pvObj");
|
||
const newSrc = "/objects/" + activeObj + ".png";
|
||
if (!obj.src.endsWith(newSrc)) obj.src = newSrc;
|
||
// object đầy khung (đã lưu tight) → scale theo tỉ lệ + viền để khớp server
|
||
const sc = (+$("objScale").value) / 100, S = obj.naturalWidth || 1000, B = +$("border").value;
|
||
const total = S / sc + 2 * B, pct = 100 * S / total;
|
||
obj.style.width = pct + "%"; obj.style.height = pct + "%";
|
||
// frame phủ toàn khung
|
||
if (selFrame) { $("pvFrame").src = assetUrl("frame", selFrame); $("pvFrame").hidden = false; } else $("pvFrame").hidden = true;
|
||
// watermark
|
||
const wm = $("pvWm");
|
||
if (selWm) {
|
||
wm.hidden = false; wm.src = assetUrl("watermark", selWm);
|
||
wm.style.width = $("wmScale").value + "%"; wm.style.height = "auto"; wm.style.opacity = $("wmOpacity").value / 100;
|
||
const pos = $("wmPos").value, m = "3%"; wm.style.inset = "auto"; wm.style.transform = "none";
|
||
const set = (o) => Object.assign(wm.style, o);
|
||
if (pos === "southeast") set({ right: m, bottom: m });
|
||
else if (pos === "southwest") set({ left: m, bottom: m });
|
||
else if (pos === "northeast") set({ right: m, top: m });
|
||
else if (pos === "northwest") set({ left: m, top: m });
|
||
else if (pos === "center") set({ left: "50%", top: "50%", transform: "translate(-50%,-50%)" });
|
||
else if (pos === "south") set({ left: "50%", bottom: m, transform: "translateX(-50%)" });
|
||
} else wm.hidden = true;
|
||
}
|
||
$("pvObj").addEventListener("load", renderPreview);
|
||
|
||
$("saveBtn").onclick = async () => {
|
||
if (!activeObj) return;
|
||
const btn = $("saveBtn"); btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Đang lưu…';
|
||
const fd = new FormData();
|
||
fd.append("object", activeObj); fd.append("frame", selFrame || ""); fd.append("watermark", selWm || "");
|
||
fd.append("bg", curBg); fd.append("obj_scale", $("objScale").value); fd.append("border", $("border").value);
|
||
fd.append("wm_opacity", $("wmOpacity").value); fd.append("wm_scale", $("wmScale").value); fd.append("wm_pos", $("wmPos").value);
|
||
try {
|
||
const d = await api("/api/compose", { method: "POST", body: fd });
|
||
if (!d.ok) throw new Error(d.error || "lỗi");
|
||
const el = document.createElement("div"); el.className = "result";
|
||
el.innerHTML = `<img class="thumb" src="${d.url}" /><div class="meta"><span>✅ ${d.output}</span><a href="${d.url}" download>Tải</a></div>`;
|
||
$("composed").prepend(el); toast("Đã lưu ảnh ghép: " + d.output);
|
||
} catch (e) { toast("Lỗi: " + e.message, "err"); }
|
||
finally { btn.disabled = false; btn.textContent = "💾 Lưu ảnh ghép"; }
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|