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:
commit
0b54e4ca06
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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">⬤ 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>
|
||||||
Loading…
Reference in New Issue