Product Image Studio Option 1: tách nền + ghép frame/watermark

Flask + rembg (u2net/isnet/birefnet) + Pillow + OpenCV.
Tab Tách nền (camera, so sánh 3 mức, polygon crop, cache) + Tab Ghép Frame
(danh sách frame/watermark, tỉ lệ object, nền màu/trong suốt). UI responsive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph 2026-06-29 14:56:04 +07:00
commit 0b54e4ca06
14 changed files with 1222 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# Python
__pycache__/
*.pyc
.venv/
venv/
# Model cache rembg
.u2net/
# Dữ liệu runtime (giữ thư mục, bỏ nội dung)
cache/*
output/*
objects/*
assets/frame/*
assets/watermark/*
!**/.gitkeep

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# Product Image Studio — Tách nền & Ghép Frame
Công cụ web xử lý ảnh sản phẩm cho ecom: **tách nền** + **ghép frame/watermark**, chạy local, miễn phí.
Stack: **Flask + rembg (U²-Net/ISNet/BiRefNet ONNX) + Pillow + OpenCV**.
## Tính năng
**Tab 1 — Tách nền**
- Xóa nền AI (chọn model: u2net / isnet / birefnet), chống cháy sáng giữ chi tiết mép.
- Chụp ảnh trực tiếp từ camera, hoặc upload nhiều ảnh / kéo-thả.
- So sánh 3 mức chất lượng (low/medium/high) chạy song song.
- Vùng chọn đa điểm (polygon) ôm sát object, chỉnh tự do để cắt ngoài vùng.
- Review zoom + lật ảnh gốc; cache cutout để tinh chỉnh không xử lý lại.
- Lưu object (1:1 trong suốt) vào thư viện.
**Tab 2 — Ghép Frame**
- Danh sách nhiều frame & watermark, chọn là preview đổi ngay.
- Chỉnh tỉ lệ object, vị trí/độ mờ/cỡ watermark, viền.
- Nền: trong suốt hoặc bảng màu / màu tùy chọn.
- Lưu ra file (PNG nếu trong suốt, JPG nếu nền màu).
UI responsive cho desktop / tablet / mobile.
## Chạy nhanh
```bash
./run.sh
```
Script tự tạo virtualenv, cài dependencies và khởi động server tại <http://localhost:8001>.
Lần đầu sẽ tải model ONNX (~170MB), sau đó chạy offline.
### Thủ công
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python server.py
```
## Ghi chú
- Apple Silicon: server ép `CPUExecutionProvider` (CoreML biên dịch u2net bị treo).
- `cache/`, `output/`, `objects/`, `assets/` là dữ liệu runtime (đã gitignore).
- Camera trực tiếp cần `localhost` hoặc HTTPS; qua IP LAN sẽ tự dùng camera gốc của máy.

0
assets/.gitkeep Normal file
View File

BIN
assets/frame.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

0
assets/frame/.gitkeep Normal file
View File

BIN
assets/watermark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

0
cache/.gitkeep vendored Normal file
View File

0
objects/.gitkeep Normal file
View File

0
output/.gitkeep Normal file
View File

6
requirements.txt Normal file
View File

@ -0,0 +1,6 @@
flask>=3.0
rembg>=2.0.50
pillow>=10.0
onnxruntime>=1.16
opencv-python-headless>=4.8
numpy>=1.24

29
run.sh Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Setup + chạy server tách nền / ghép frame (Option 1 - Python/rembg)
# Dùng: ./run.sh
set -euo pipefail
cd "$(dirname "$0")"
PY="${PYTHON:-python3}"
command -v "$PY" >/dev/null 2>&1 || { echo "✗ Không tìm thấy python3. Cài Python 3.9+ trước."; exit 1; }
if [ ! -d .venv ]; then
echo "→ Tạo virtualenv (.venv)…"
"$PY" -m venv .venv
fi
# shellcheck disable=SC1091
source .venv/bin/activate
echo "→ Cài / cập nhật dependencies…"
python -m pip install -q --upgrade pip
python -m pip install -q -r requirements.txt
echo ""
echo "──────────────────────────────────────────────"
echo " Server: http://localhost:8001"
echo " Lần đầu sẽ tự tải model ONNX (~170MB)."
echo " Dừng: Ctrl+C"
echo "──────────────────────────────────────────────"
echo ""
exec python server.py

443
server.py Normal file
View File

@ -0,0 +1,443 @@
"""
Option 1 Tách nền + Ghép Frame/Watermark cho ảnh sản phẩm
Stack: Flask + rembg (U^2-Net ONNX, local) + Pillow + OpenCV
Kiến trúc 2 giai đoạn:
Tab 1 (Tách nền): xóa nền object 1:1 trong suốt LƯU vào thư viện (trả name).
Tab 2 (Ghép Frame): chọn object + frame + watermark (danh sách) SAVE render ra file.
"""
import io
import os
import re
import json
import time
import uuid
import hashlib
import threading
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, request, jsonify, send_from_directory, send_file
from rembg import remove, new_session
from PIL import Image, ImageOps, ImageEnhance, ImageFilter, ImageChops, ImageDraw
BASE = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(BASE, "assets") # assets/frame/*.png, assets/watermark/*.png
OBJECTS = os.path.join(BASE, "objects") # object đã tách nền (thư viện Tab 1)
OUTPUT = os.path.join(BASE, "output")
CACHE = os.path.join(BASE, "cache") # cutout sau remove-bg, tái dùng
for d in (os.path.join(ASSETS, "frame"), os.path.join(ASSETS, "watermark"), OBJECTS, OUTPUT, CACHE):
os.makedirs(d, exist_ok=True)
app = Flask(__name__, static_folder="static", static_url_path="")
# ============================ MODEL / SESSION ============================
SESSIONS = {}
SESSION_LOCK = threading.Lock()
ALLOWED_MODELS = {"u2net", "isnet-general-use", "birefnet-general-lite"}
COMPARE_PRESETS = {
"low": {"model": "u2net", "anti_blowout": False, "recover": 0},
"medium": {"model": "isnet-general-use", "anti_blowout": True, "recover": 2},
"high": {"model": "birefnet-general-lite", "anti_blowout": True, "recover": 3},
}
def get_session(model: str):
if model not in ALLOWED_MODELS:
model = "u2net"
with SESSION_LOCK:
if model not in SESSIONS:
print(f"[model] đang khởi tạo '{model}'", flush=True)
t = time.time()
# Ép CPUExecutionProvider: CoreML trên Apple Silicon biên dịch u2net → treo.
try:
SESSIONS[model] = new_session(model, providers=["CPUExecutionProvider"])
except TypeError:
os.environ["ONNXRUNTIME_EXECUTION_PROVIDERS"] = "[CPUExecutionProvider]"
SESSIONS[model] = new_session(model)
print(f"[model] '{model}' sẵn sàng sau {time.time() - t:.1f}s", flush=True)
return SESSIONS[model]
# ============================ REMOVE BACKGROUND ============================
def remove_object(src, opt):
"""Xóa nền, tối ưu chống cháy sáng để không mất góc/chi tiết mép."""
rgba = src.convert("RGBA")
session = get_session(opt["model"])
if not opt["anti_blowout"]:
return remove(rgba, session=session, post_process_mask=True)
work = ImageEnhance.Contrast(src.convert("RGB")).enhance(1.5)
work = ImageEnhance.Brightness(work).enhance(0.85)
mask = remove(work, session=session, only_mask=True, post_process_mask=True)
r = int(opt["recover"])
if r > 0:
k = r * 2 + 1
mask = mask.filter(ImageFilter.MaxFilter(k)).filter(ImageFilter.MinFilter(k))
out = rgba.copy()
out.putalpha(mask)
return out
def get_cut(src, img_hash, opt):
"""Cutout sau xóa nền, tái dùng cache theo (ảnh + model/anti/recover)."""
key = f"{img_hash}-{opt['model']}-{int(opt['anti_blowout'])}-{opt['recover']}"
path = os.path.join(CACHE, key + ".png")
if os.path.exists(path):
return Image.open(path).convert("RGBA"), True
cut = remove_object(src, opt)
cut.save(path)
return cut, False
def fit_square(cut, obj_scale, bg):
"""Crop sát object rồi đặt vào canvas vuông 1:1."""
bbox = cut.getbbox()
if bbox:
cut = cut.crop(bbox)
w, h = cut.size
side = max(1, int(round(max(w, h) / max(0.05, obj_scale))))
fill = (255, 255, 255, 255) if bg == "white" else (0, 0, 0, 0)
canvas = Image.new("RGBA", (side, side), fill)
canvas.alpha_composite(cut, ((side - w) // 2, (side - h) // 2))
return canvas
def object_polygon(cut, max_pts=48):
"""Đường viền (polygon chuẩn hoá 0..1) ôm sát object, suy từ alpha."""
W, H = cut.size
rect = [[0, 0], [1, 0], [1, 1], [0, 1]]
try:
import cv2
import numpy as np
except Exception: # noqa: BLE001
bb = cut.getbbox()
if not bb:
return rect
x0, y0, x1, y1 = bb
return [[x0 / W, y0 / H], [x1 / W, y0 / H], [x1 / W, y1 / H], [x0 / W, y1 / H]]
alpha = np.array(cut.getchannel("A"))
_, binimg = cv2.threshold(alpha, 10, 255, cv2.THRESH_BINARY)
cnts, _ = cv2.findContours(binimg, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts:
return rect
c = max(cnts, key=cv2.contourArea)
peri = cv2.arcLength(c, True)
eps = 0.008 * peri
approx = cv2.approxPolyDP(c, eps, True)
while len(approx) > max_pts:
eps *= 1.3
approx = cv2.approxPolyDP(c, eps, True)
if len(approx) < 3:
return rect
return [[float(p[0][0]) / W, float(p[0][1]) / H] for p in approx]
def apply_poly(cut, poly):
"""Giữ alpha bên trong polygon (chuẩn hoá), xóa ngoài."""
W, H = cut.size
pts = [(p[0] * W, p[1] * H) for p in poly]
mask = Image.new("L", (W, H), 0)
ImageDraw.Draw(mask).polygon(pts, fill=255)
out = cut.copy()
out.putalpha(ImageChops.multiply(cut.getchannel("A"), mask))
return out
def position(pos, canvas, item, margin):
cw, ch = canvas
iw, ih = item
table = {
"northwest": (margin, margin),
"north": ((cw - iw) // 2, margin),
"northeast": (cw - iw - margin, margin),
"center": ((cw - iw) // 2, (ch - ih) // 2),
"southwest": (margin, ch - ih - margin),
"south": ((cw - iw) // 2, ch - ih - margin),
"southeast": (cw - iw - margin, ch - ih - margin),
}
return table.get(pos, table["southeast"])
# ============================ TAB 1: TÁCH NỀN ============================
def make_object(src, img_hash, opt):
"""Xóa nền → (polygon crop nếu có) → object vuông 1:1 TRONG SUỐT (không frame/wm)."""
cut, cached = get_cut(src, img_hash, opt)
polygon = object_polygon(cut)
if opt.get("poly"):
cut = apply_poly(cut, opt["poly"])
canvas = fit_square(cut, 1.0, "transparent") # lưu ôm sát; tỉ lệ chỉnh ở bước ghép frame
out_name = f"{uuid.uuid4().hex[:12]}.png"
canvas.save(os.path.join(OUTPUT, out_name))
return out_name, cached, polygon
def parse_poly(form):
raw = form.get("poly")
if not raw:
return None
try:
pts = [[max(0.0, min(1.0, float(x))), max(0.0, min(1.0, float(y)))]
for x, y in json.loads(raw)]
except (ValueError, TypeError):
return None
return pts if len(pts) >= 3 else None
def build_opt(form):
return {
"model": form.get("model", "u2net"),
"anti_blowout": form.get("anti_blowout", "true") == "true",
"recover": int(form.get("recover", 2) or 0),
"poly": parse_poly(form),
}
def save_original(data, filename):
ext = os.path.splitext(filename)[1].lower()
if ext not in (".jpg", ".jpeg", ".png", ".webp"):
ext = ".png"
name = f"orig-{uuid.uuid4().hex[:12]}{ext}"
with open(os.path.join(OUTPUT, name), "wb") as fh:
fh.write(data)
return name
def run_compare(src, img_hash, filename, original, base_opt):
def run(level):
opt = dict(base_opt, **COMPARE_PRESETS[level])
t = time.time()
out, cached, polygon = make_object(src, img_hash, opt)
print(f"[compare] {filename} · {level}{'cache' if cached else f'{time.time()-t:.1f}s'}", flush=True)
return {"name": filename, "output": out, "original": original,
"ok": True, "level": level, "cached": cached, "polygon": polygon}
levels = ("low", "medium", "high")
with ThreadPoolExecutor(max_workers=3) as ex:
futures = {lv: ex.submit(run, lv) for lv in levels}
out = []
for lv in levels:
try:
out.append(futures[lv].result())
except Exception as e: # noqa: BLE001
print(f"[compare] {filename} · {lv}{e}", flush=True)
out.append({"name": filename, "ok": False, "level": lv, "error": str(e)})
return out
@app.post("/api/process")
def process():
opt = build_opt(request.form)
compare = request.form.get("compare", "false") == "true"
files = request.files.getlist("images")
if not files:
return jsonify(error="chưa chọn ảnh"), 400
total = len(files)
print(f"[batch] nhận {total} ảnh · {'so sánh 3 mức' if compare else opt['model']}", flush=True)
results = []
for i, f in enumerate(files, 1):
t = time.time()
try:
data = f.read()
img_hash = hashlib.sha1(data).hexdigest()[:16]
original = save_original(data, f.filename)
src = ImageOps.exif_transpose(Image.open(io.BytesIO(data))).convert("RGBA")
if compare:
results.extend(run_compare(src, img_hash, f.filename, original, opt))
else:
out, cached, polygon = make_object(src, img_hash, opt)
results.append({"name": f.filename, "output": out, "original": original,
"ok": True, "level": None, "cached": cached, "polygon": polygon})
print(f"[{i}/{total}] {f.filename}{time.time() - t:.1f}s", flush=True)
except Exception as e: # noqa: BLE001
print(f"[{i}/{total}] {f.filename}{e}", flush=True)
results.append({"name": f.filename, "ok": False, "error": str(e)})
print("[batch] hoàn tất.", flush=True)
return jsonify(results=results)
@app.post("/api/recrop")
def recrop():
"""Render lại 1 object với vùng chọn polygon, dùng lại ảnh gốc + cache cutout."""
original = request.form.get("original", "")
path = os.path.join(OUTPUT, os.path.basename(original))
if not original or not os.path.exists(path):
return jsonify(error="không tìm thấy ảnh gốc"), 404
opt = build_opt(request.form)
level = request.form.get("level", "")
if level in COMPARE_PRESETS:
opt.update(COMPARE_PRESETS[level])
with open(path, "rb") as fh:
data = fh.read()
img_hash = hashlib.sha1(data).hexdigest()[:16]
src = ImageOps.exif_transpose(Image.open(io.BytesIO(data))).convert("RGBA")
out, cached, polygon = make_object(src, img_hash, opt)
return jsonify(ok=True, output=out, original=original, level=level or None,
cached=cached, polygon=polygon)
# ============================ THƯ VIỆN OBJECT ============================
def safe_name(name):
name = re.sub(r"[^\w\-]+", "_", (name or "").strip())
return name[:60] or uuid.uuid4().hex[:8]
@app.post("/api/save-object")
def save_object():
out = os.path.basename(request.form.get("output", ""))
src_path = os.path.join(OUTPUT, out)
if not out or not os.path.exists(src_path):
return jsonify(error="không tìm thấy ảnh đã xử lý"), 404
name = safe_name(request.form.get("name"))
Image.open(src_path).convert("RGBA").save(os.path.join(OBJECTS, name + ".png"))
print(f"[object] đã lưu '{name}'", flush=True)
return jsonify(ok=True, name=name, url=f"/objects/{name}.png")
@app.get("/api/objects")
def list_objects():
items = [{"name": fn[:-4], "url": f"/objects/{fn}"}
for fn in sorted(os.listdir(OBJECTS)) if fn.endswith(".png")]
return jsonify(items=items)
@app.delete("/api/objects/<name>")
def del_object(name):
p = os.path.join(OBJECTS, os.path.basename(name) + ".png")
if os.path.exists(p):
os.remove(p)
return jsonify(ok=True)
@app.get("/objects/<path:name>")
def serve_object(name):
return send_from_directory(OBJECTS, name)
# ============================ TAB 2: FRAME / WATERMARK ============================
def bg_color(bg):
"""Trả (r,g,b,255) cho mã hex, hoặc None nếu trong suốt."""
if not bg or bg == "transparent":
return None
h = bg.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
try:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255)
except (ValueError, IndexError):
return None
def asset_file(kind, aid):
return os.path.join(ASSETS, kind, f"{os.path.basename(aid)}.png")
@app.post("/api/asset")
def add_asset():
kind = request.form.get("kind")
if kind not in ("frame", "watermark"):
return jsonify(error="kind không hợp lệ"), 400
f = request.files.get("file")
if not f:
return jsonify(error="thiếu file"), 400
aid = uuid.uuid4().hex[:12]
Image.open(f.stream).convert("RGBA").save(asset_file(kind, aid))
return jsonify(ok=True, id=aid, url=f"/api/asset/{kind}/{aid}")
@app.get("/api/asset/<kind>")
def list_assets(kind):
d = os.path.join(ASSETS, kind)
items = []
if os.path.isdir(d):
for fn in sorted(os.listdir(d)):
if fn.endswith(".png"):
aid = fn[:-4]
items.append({"id": aid, "url": f"/api/asset/{kind}/{aid}"})
return jsonify(items=items)
@app.get("/api/asset/<kind>/<aid>")
def get_asset(kind, aid):
p = asset_file(kind, aid)
if not os.path.exists(p):
return jsonify(error="chưa có"), 404
return send_file(p, mimetype="image/png")
@app.delete("/api/asset/<kind>/<aid>")
def del_asset(kind, aid):
p = asset_file(kind, aid)
if os.path.exists(p):
os.remove(p)
return jsonify(ok=True)
@app.post("/api/compose")
def compose():
"""Ghép object + frame + watermark → render & LƯU file, trả filename."""
name = os.path.basename(request.form.get("object", ""))
op = os.path.join(OBJECTS, name + ".png")
if not name or not os.path.exists(op):
return jsonify(error="không tìm thấy object"), 404
obj = Image.open(op).convert("RGBA")
obj_scale = float(request.form.get("obj_scale", 100)) / 100
obj = fit_square(obj, obj_scale, "transparent") # đặt lại tỉ lệ object trong khung
fill = bg_color(request.form.get("bg", "transparent")) # None = trong suốt
border = int(request.form.get("border", 0) or 0)
frame_id = request.form.get("frame", "")
wm_id = request.form.get("watermark", "")
wm_opacity = float(request.form.get("wm_opacity", 60)) / 100
wm_pos = request.form.get("wm_pos", "southeast")
wm_scale = float(request.form.get("wm_scale", 25)) / 100
if fill:
canvas = Image.new("RGBA", obj.size, fill)
canvas.alpha_composite(obj)
else:
canvas = obj.copy()
if border > 0:
canvas = ImageOps.expand(canvas, border=border, fill=fill or (0, 0, 0, 0))
if frame_id and os.path.exists(asset_file("frame", frame_id)):
frame = Image.open(asset_file("frame", frame_id)).convert("RGBA").resize(canvas.size)
canvas.alpha_composite(frame)
if wm_id and os.path.exists(asset_file("watermark", wm_id)):
wm = Image.open(asset_file("watermark", wm_id)).convert("RGBA")
tw = max(1, int(canvas.width * wm_scale))
th = max(1, int(wm.height * tw / wm.width))
wm = wm.resize((tw, th))
wm.putalpha(wm.split()[3].point(lambda a: int(a * wm_opacity)))
margin = int(canvas.width * 0.03)
x, y = position(wm_pos, canvas.size, wm.size, margin)
canvas.alpha_composite(wm, (x, y))
uid = uuid.uuid4().hex[:12]
if fill: # nền đặc → JPG
out_name = f"{uid}.jpg"
canvas.convert("RGB").save(os.path.join(OUTPUT, out_name), quality=92)
else: # trong suốt → PNG
out_name = f"{uid}.png"
canvas.save(os.path.join(OUTPUT, out_name))
print(f"[compose] {name} + frame={frame_id or '-'} + wm={wm_id or '-'}{out_name}", flush=True)
return jsonify(ok=True, output=out_name, url=f"/output/{out_name}")
@app.get("/")
def index():
return send_from_directory("static", "index.html")
@app.get("/output/<path:name>")
def output(name):
return send_from_directory(OUTPUT, name)
if __name__ == "__main__":
print("→ Option 1 (Python/rembg) đang khởi động…", flush=True)
get_session("u2net")
print("→ Sẵn sàng tại http://localhost:8001", flush=True)
app.run(host="0.0.0.0", port=8001, debug=False, threaded=True)

688
static/index.html Normal file
View File

@ -0,0 +1,688 @@
<!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>