product-image-studio-option1/static/index.html

689 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">&nbsp;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>