feat: add Listing anh Free tool (Pillow design + Wangp background, web UI)
Browse files- .gitattributes +0 -0
- backend/app/api/listing_free.py +245 -0
- backend/app/main.py +2 -1
- backend/app/services/listing_free/__init__.py +3 -0
- backend/app/services/listing_free/design.py +265 -0
- backend/app/services/listing_free/generator.py +114 -0
- backend/app/services/listing_free/wangp.py +138 -0
- frontend/src/App.jsx +2 -0
- frontend/src/components/Sidebar.jsx +2 -1
- frontend/src/pages/ListingFree.jsx +545 -0
.gitattributes
CHANGED
|
Binary files a/.gitattributes and b/.gitattributes differ
|
|
|
backend/app/api/listing_free.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, shutil, time, uuid, threading, tempfile, zipfile, base64, io
|
| 2 |
+
from fastapi import APIRouter, File, UploadFile, Form, HTTPException, BackgroundTasks
|
| 3 |
+
from fastapi.responses import FileResponse
|
| 4 |
+
from PIL import Image
|
| 5 |
+
|
| 6 |
+
from ..core.files import sub_dir
|
| 7 |
+
from ..services.listing_free.generator import generate_listing_free, set_session, get_session, del_session
|
| 8 |
+
from ..services.listing_free.design import ALIAS, COLORS, render as design_render
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/listing-free", tags=["listing-free"])
|
| 11 |
+
|
| 12 |
+
_font_families = {k: v for k, v in sorted(ALIAS.items())}
|
| 13 |
+
_color_names = list(COLORS.keys())
|
| 14 |
+
|
| 15 |
+
def _new_sid():
|
| 16 |
+
return f"lfree_{time.strftime('%y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
| 17 |
+
|
| 18 |
+
def _get(sid):
|
| 19 |
+
s = get_session(sid)
|
| 20 |
+
if not s:
|
| 21 |
+
raise HTTPException(404, "Session not found")
|
| 22 |
+
return s
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post("/upload")
|
| 26 |
+
async def upload_files(
|
| 27 |
+
product: UploadFile = File(None),
|
| 28 |
+
background: UploadFile = File(None),
|
| 29 |
+
model: UploadFile = File(None),
|
| 30 |
+
):
|
| 31 |
+
sid = _new_sid()
|
| 32 |
+
sess_dir = sub_dir(sid, "listing_free")
|
| 33 |
+
os.makedirs(sess_dir, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
def save_img(upload, name):
|
| 36 |
+
if not upload or not upload.filename:
|
| 37 |
+
return None
|
| 38 |
+
ext = os.path.splitext(upload.filename)[1] or ".jpg"
|
| 39 |
+
path = os.path.join(sess_dir, f"{name}{ext}")
|
| 40 |
+
with open(path, "wb") as f:
|
| 41 |
+
f.write(upload.file.read())
|
| 42 |
+
return path
|
| 43 |
+
|
| 44 |
+
product_path = save_img(product, "product")
|
| 45 |
+
bg_path = save_img(background, "background")
|
| 46 |
+
model_path = save_img(model, "model")
|
| 47 |
+
|
| 48 |
+
session = {
|
| 49 |
+
"id": sid, "status": "ready", "dir": sess_dir,
|
| 50 |
+
"product_image": product_path, "background_image": bg_path, "model_image": model_path,
|
| 51 |
+
"results": [], "errors": [], "progress": {"current": 0, "total": 0},
|
| 52 |
+
"last_status": None, "items": [],
|
| 53 |
+
"created_at": time.time(),
|
| 54 |
+
}
|
| 55 |
+
set_session(sid, session)
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"ok": True, "session_id": sid,
|
| 59 |
+
"product": os.path.basename(product_path) if product_path else None,
|
| 60 |
+
"background": os.path.basename(bg_path) if bg_path else None,
|
| 61 |
+
"model": os.path.basename(model_path) if model_path else None,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.post("/design")
|
| 66 |
+
async def apply_design(body: dict):
|
| 67 |
+
sid = body.get("session_id")
|
| 68 |
+
items = body.get("items", [])
|
| 69 |
+
if not sid or not items:
|
| 70 |
+
raise HTTPException(400, "Missing session_id or items")
|
| 71 |
+
|
| 72 |
+
s = _get(sid)
|
| 73 |
+
if s["status"] == "generating":
|
| 74 |
+
raise HTTPException(400, "Already generating")
|
| 75 |
+
|
| 76 |
+
s["items"] = items
|
| 77 |
+
s["results"] = []
|
| 78 |
+
s["errors"] = []
|
| 79 |
+
s["status"] = "generating"
|
| 80 |
+
|
| 81 |
+
product_img = s.get("product_image")
|
| 82 |
+
model_img = s.get("model_image")
|
| 83 |
+
|
| 84 |
+
thread = threading.Thread(
|
| 85 |
+
target=generate_listing_free,
|
| 86 |
+
args=(s, items, product_img, model_img),
|
| 87 |
+
daemon=True
|
| 88 |
+
)
|
| 89 |
+
thread.start()
|
| 90 |
+
|
| 91 |
+
return {"ok": True, "session_id": sid, "total": len(items)}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@router.post("/bg-only")
|
| 95 |
+
async def generate_background_only(
|
| 96 |
+
session_id: str = Form(...),
|
| 97 |
+
prompt: str = Form(""),
|
| 98 |
+
type: str = Form("display"),
|
| 99 |
+
label: str = Form("Background"),
|
| 100 |
+
):
|
| 101 |
+
s = _get(session_id)
|
| 102 |
+
|
| 103 |
+
bg_path = os.path.join(s["dir"], f"bg_{type}_{int(time.time())}.png")
|
| 104 |
+
product_img = s.get("product_image")
|
| 105 |
+
|
| 106 |
+
item = {
|
| 107 |
+
"type": type, "label": label,
|
| 108 |
+
"scene_prompt": prompt, "template": "simple",
|
| 109 |
+
"text_blocks": [], "lang": "vi",
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
from ..services.listing_free.wangp import WanGPClient
|
| 113 |
+
wan = WanGPClient()
|
| 114 |
+
try:
|
| 115 |
+
ok = wan.generate_one(item, bg_path, product_img)
|
| 116 |
+
if ok:
|
| 117 |
+
s["background_image"] = bg_path
|
| 118 |
+
return {"ok": True, "file": os.path.basename(bg_path)}
|
| 119 |
+
raise Exception("No image generated")
|
| 120 |
+
except Exception as e:
|
| 121 |
+
return {"ok": False, "error": str(e)}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@router.get("/session/{sid}")
|
| 125 |
+
def get_session_status(sid: str):
|
| 126 |
+
s = _get(sid)
|
| 127 |
+
return {
|
| 128 |
+
"id": s["id"], "status": s["status"],
|
| 129 |
+
"items": s.get("items", []),
|
| 130 |
+
"results": s.get("results", []),
|
| 131 |
+
"errors": s.get("errors", []),
|
| 132 |
+
"progress": s["progress"],
|
| 133 |
+
"last_status": s["last_status"],
|
| 134 |
+
"product_image": os.path.basename(s["product_image"]) if s.get("product_image") else None,
|
| 135 |
+
"background_image": os.path.basename(s["background_image"]) if s.get("background_image") else None,
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.get("/file/{sid}/{filename}")
|
| 140 |
+
def serve_file(sid: str, filename: str):
|
| 141 |
+
s = _get(sid)
|
| 142 |
+
path = os.path.join(s["dir"], filename)
|
| 143 |
+
if not os.path.exists(path):
|
| 144 |
+
raise HTTPException(404, "File not found")
|
| 145 |
+
return FileResponse(path)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.get("/download/{sid}")
|
| 149 |
+
def download_all(sid: str, background_tasks: BackgroundTasks):
|
| 150 |
+
s = _get(sid)
|
| 151 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
|
| 152 |
+
zip_path = tmp.name
|
| 153 |
+
tmp.close()
|
| 154 |
+
|
| 155 |
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 156 |
+
for r in s.get("results") or []:
|
| 157 |
+
for img in r.get("images") or []:
|
| 158 |
+
if os.path.exists(img):
|
| 159 |
+
arcname = f"{r['type']}_{os.path.basename(img)}"
|
| 160 |
+
zf.write(img, arcname)
|
| 161 |
+
for key in ("product_image", "background_image", "model_image"):
|
| 162 |
+
val = s.get(key)
|
| 163 |
+
if val and os.path.exists(val):
|
| 164 |
+
zf.write(val, os.path.basename(val))
|
| 165 |
+
|
| 166 |
+
background_tasks.add_task(os.unlink, zip_path)
|
| 167 |
+
return FileResponse(zip_path, filename=f"listing_free_{sid}.zip", media_type="application/zip")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@router.delete("/session/{sid}")
|
| 171 |
+
def delete_session(sid: str):
|
| 172 |
+
s = _get(sid)
|
| 173 |
+
shutil.rmtree(s["dir"], ignore_errors=True)
|
| 174 |
+
del_session(sid)
|
| 175 |
+
return {"ok": True}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@router.get("/fonts")
|
| 179 |
+
def list_fonts():
|
| 180 |
+
return {
|
| 181 |
+
"families": _font_families,
|
| 182 |
+
"colors": _color_names,
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@router.get("/templates")
|
| 187 |
+
def list_templates():
|
| 188 |
+
return {
|
| 189 |
+
"templates": [
|
| 190 |
+
{
|
| 191 |
+
"id": "simple",
|
| 192 |
+
"name": "Simple Poster",
|
| 193 |
+
"description": "Elegant eyebrow + headline + subhead + tag",
|
| 194 |
+
"fields": [
|
| 195 |
+
{"name": "text_pos", "label": "Text position", "type": "select", "options": ["bottom-left", "bottom-center", "bottom-right", "center", "top-left", "top-center", "top-right"]},
|
| 196 |
+
]
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"id": "multicolor",
|
| 200 |
+
"name": "Multicolor Headline",
|
| 201 |
+
"description": "Big stacked headline with per-word colors + badge",
|
| 202 |
+
"fields": [
|
| 203 |
+
{"name": "text_pos", "label": "Text position", "type": "select", "options": ["bottom-left", "top-left"]},
|
| 204 |
+
{"name": "stack", "label": "Stack words", "type": "boolean"},
|
| 205 |
+
]
|
| 206 |
+
},
|
| 207 |
+
{
|
| 208 |
+
"id": "feature_panel",
|
| 209 |
+
"name": "Feature Panel",
|
| 210 |
+
"description": "Rounded side panel with title + icon rows",
|
| 211 |
+
"fields": [
|
| 212 |
+
{"name": "panel_side", "label": "Panel side", "type": "select", "options": ["left", "right"]},
|
| 213 |
+
]
|
| 214 |
+
},
|
| 215 |
+
]
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@router.post("/preview")
|
| 220 |
+
async def preview_design(body: dict):
|
| 221 |
+
"""Preview design on a product/background image without saving to session."""
|
| 222 |
+
item = body.get("item", {})
|
| 223 |
+
bg_base64 = body.get("background_image")
|
| 224 |
+
|
| 225 |
+
W, H = 1088, 1088
|
| 226 |
+
if bg_base64:
|
| 227 |
+
try:
|
| 228 |
+
hdr, data = bg_base64.split(",", 1) if "," in bg_base64 else ("", bg_base64)
|
| 229 |
+
img_data = base64.b64decode(data)
|
| 230 |
+
buf = io.BytesIO(img_data)
|
| 231 |
+
bg = Image.open(buf).convert("RGBA")
|
| 232 |
+
bg = bg.resize((W, H), Image.LANCZOS)
|
| 233 |
+
except Exception:
|
| 234 |
+
bg = Image.new("RGBA", (W, H), (255, 255, 255, 255))
|
| 235 |
+
else:
|
| 236 |
+
bg = Image.new("RGBA", (W, H), (255, 255, 255, 255))
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
design_render(bg, item)
|
| 240 |
+
buf = io.BytesIO()
|
| 241 |
+
bg.convert("RGB").save(buf, format="JPEG", quality=90)
|
| 242 |
+
b64 = base64.b64encode(buf.getvalue()).decode()
|
| 243 |
+
return {"ok": True, "image": f"data:image/jpeg;base64,{b64}"}
|
| 244 |
+
except Exception as e:
|
| 245 |
+
return {"ok": False, "error": str(e)}
|
backend/app/main.py
CHANGED
|
@@ -31,7 +31,7 @@ _here = os.path.dirname(os.path.abspath(__file__))
|
|
| 31 |
_parent = os.path.dirname(_here)
|
| 32 |
sys.path.insert(0, _parent)
|
| 33 |
|
| 34 |
-
from .api import common, download, cut, shuffle, dub, listing, video, auth, drive, history_api, flux2
|
| 35 |
|
| 36 |
app = FastAPI(title="TNT Group - AI Content Create", version="1.0.0")
|
| 37 |
|
|
@@ -54,6 +54,7 @@ app.include_router(auth.router)
|
|
| 54 |
app.include_router(drive.router)
|
| 55 |
app.include_router(history_api.router)
|
| 56 |
app.include_router(flux2.router)
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
@app.get("/api/health")
|
|
|
|
| 31 |
_parent = os.path.dirname(_here)
|
| 32 |
sys.path.insert(0, _parent)
|
| 33 |
|
| 34 |
+
from .api import common, download, cut, shuffle, dub, listing, video, auth, drive, history_api, flux2, listing_free
|
| 35 |
|
| 36 |
app = FastAPI(title="TNT Group - AI Content Create", version="1.0.0")
|
| 37 |
|
|
|
|
| 54 |
app.include_router(drive.router)
|
| 55 |
app.include_router(history_api.router)
|
| 56 |
app.include_router(flux2.router)
|
| 57 |
+
app.include_router(listing_free.router)
|
| 58 |
|
| 59 |
|
| 60 |
@app.get("/api/health")
|
backend/app/services/listing_free/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .design import render, font_of, col, ALIAS
|
| 2 |
+
from .wangp import generate_one, WanGPClient
|
| 3 |
+
from .generator import generate_listing_free
|
backend/app/services/listing_free/design.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pathlib, math, os
|
| 2 |
+
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
| 3 |
+
|
| 4 |
+
_here = os.path.dirname(os.path.abspath(__file__))
|
| 5 |
+
FONT_DIR = pathlib.Path(_here) / "fonts"
|
| 6 |
+
|
| 7 |
+
FONT_URLS = {
|
| 8 |
+
"Montserrat-ExtraBold.ttf": "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat%5Bwght%5D.ttf",
|
| 9 |
+
"Montserrat-SemiBold.ttf": "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat%5Bwght%5D.ttf",
|
| 10 |
+
"PlayfairDisplay-Bold.ttf": "https://github.com/google/fonts/raw/main/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf",
|
| 11 |
+
"GreatVibes-Regular.ttf": "https://github.com/google/fonts/raw/main/ofl/greatvibes/GreatVibes-Regular.ttf",
|
| 12 |
+
"DancingScript-Bold.ttf": "https://github.com/google/fonts/raw/main/ofl/dancingscript/DancingScript%5Bwght%5D.ttf",
|
| 13 |
+
"Parisienne-Regular.ttf": "https://github.com/google/fonts/raw/main/ofl/parisienne/Parisienne-Regular.ttf",
|
| 14 |
+
"Cormorant-Bold.ttf": "https://github.com/google/fonts/raw/main/ofl/cormorant/Cormorant%5Bwght%5D.ttf",
|
| 15 |
+
"Sacramento-Regular.ttf": "https://github.com/google/fonts/raw/main/ofl/sacramento/Sacramento-Regular.ttf",
|
| 16 |
+
"Pacifico-Regular.ttf": "https://github.com/google/fonts/raw/main/ofl/pacifico/Pacifico-Regular.ttf",
|
| 17 |
+
"Oswald-Bold.ttf": "https://github.com/google/fonts/raw/main/ofl/oswald/Oswald%5Bwght%5D.ttf",
|
| 18 |
+
"BebasNeue-Regular.ttf": "https://github.com/google/fonts/raw/main/ofl/bebasneue/BebasNeue-Regular.ttf",
|
| 19 |
+
"Lobster-Regular.ttf": "https://github.com/google/fonts/raw/main/ofl/lobster/Lobster-Regular.ttf",
|
| 20 |
+
"Cinzel-Bold.ttf": "https://github.com/google/fonts/raw/main/ofl/cinzel/Cinzel%5Bwght%5D.ttf",
|
| 21 |
+
"BeVietnamPro-ExtraBold.ttf": "https://github.com/google/fonts/raw/main/ofl/bevietnampro/BeVietnamPro-ExtraBold.ttf",
|
| 22 |
+
"BeVietnamPro-Bold.ttf": "https://github.com/google/fonts/raw/main/ofl/bevietnampro/BeVietnamPro-Bold.ttf",
|
| 23 |
+
"BeVietnamPro-Medium.ttf": "https://github.com/google/fonts/raw/main/ofl/bevietnampro/BeVietnamPro-Medium.ttf",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
_font_ensure_done = False
|
| 27 |
+
|
| 28 |
+
def ensure_fonts():
|
| 29 |
+
global _font_ensure_done
|
| 30 |
+
if _font_ensure_done:
|
| 31 |
+
return
|
| 32 |
+
import httpx
|
| 33 |
+
FONT_DIR.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
for name, url in FONT_URLS.items():
|
| 35 |
+
fp = FONT_DIR / name
|
| 36 |
+
if fp.exists():
|
| 37 |
+
continue
|
| 38 |
+
try:
|
| 39 |
+
r = httpx.get(url, timeout=60, follow_redirects=True)
|
| 40 |
+
if r.status_code == 200:
|
| 41 |
+
fp.write_bytes(r.content)
|
| 42 |
+
except Exception:
|
| 43 |
+
pass
|
| 44 |
+
_font_ensure_done = True
|
| 45 |
+
|
| 46 |
+
ACCENT = (233, 78, 96)
|
| 47 |
+
GOLD = (196, 160, 90)
|
| 48 |
+
DARK = (60, 40, 45)
|
| 49 |
+
WHITE = (255, 255, 255)
|
| 50 |
+
|
| 51 |
+
ALIAS = {
|
| 52 |
+
"script": "GreatVibes-Regular.ttf", "script_bold": "DancingScript-Bold.ttf", "handwrite": "Pacifico-Regular.ttf",
|
| 53 |
+
"calligraphy": "Sacramento-Regular.ttf", "retro": "Lobster-Regular.ttf",
|
| 54 |
+
"serif": "PlayfairDisplay-Bold.ttf", "serif_classic": "Cormorant-Bold.ttf", "serif_luxury": "Cinzel-Bold.ttf",
|
| 55 |
+
"sans": "Montserrat-ExtraBold.ttf", "condensed": "Oswald-Bold.ttf", "impact": "BebasNeue-Regular.ttf",
|
| 56 |
+
"vn": "BeVietnamPro-ExtraBold.ttf", "vn_bold": "BeVietnamPro-Bold.ttf", "vn_med": "BeVietnamPro-Medium.ttf",
|
| 57 |
+
}
|
| 58 |
+
VN_SCRIPTS = {"script", "script_bold", "handwrite", "calligraphy", "retro"}
|
| 59 |
+
COLORS = {"white": WHITE, "pink": ACCENT, "accent": ACCENT, "gold": GOLD, "dark": DARK, "black": (20, 20, 20),
|
| 60 |
+
"red": (200, 40, 50), "green": (70, 150, 90), "gray": (120, 120, 120)}
|
| 61 |
+
|
| 62 |
+
def lf(name, size):
|
| 63 |
+
ensure_fonts()
|
| 64 |
+
try:
|
| 65 |
+
return ImageFont.truetype(str(FONT_DIR / name), size)
|
| 66 |
+
except Exception:
|
| 67 |
+
try:
|
| 68 |
+
return ImageFont.truetype(name, size)
|
| 69 |
+
except Exception:
|
| 70 |
+
return ImageFont.load_default()
|
| 71 |
+
|
| 72 |
+
def font_of(kind, size, lang="en"):
|
| 73 |
+
if lang == "vi" and kind in VN_SCRIPTS:
|
| 74 |
+
kind = "vn"
|
| 75 |
+
if lang == "vi" and kind in ("sans", "condensed", "impact"):
|
| 76 |
+
kind = "vn"
|
| 77 |
+
return lf(ALIAS.get(kind, ALIAS["sans"]), size)
|
| 78 |
+
|
| 79 |
+
def col(c):
|
| 80 |
+
return COLORS.get(c, c if isinstance(c, tuple) else WHITE)
|
| 81 |
+
|
| 82 |
+
def text_shadow(base, xy, text, font, fill, blur=None, stroke=0):
|
| 83 |
+
x, y = xy
|
| 84 |
+
size = font.size
|
| 85 |
+
if blur is None:
|
| 86 |
+
blur = size * 0.05
|
| 87 |
+
sh = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
| 88 |
+
ImageDraw.Draw(sh).text((x, y), text, font=font, fill=(0, 0, 0, 150))
|
| 89 |
+
base.alpha_composite(sh.filter(ImageFilter.GaussianBlur(blur)))
|
| 90 |
+
ImageDraw.Draw(base).text((x, y), text, font=font, fill=fill,
|
| 91 |
+
stroke_width=stroke, stroke_fill=(0, 0, 0, 90) if stroke else None)
|
| 92 |
+
|
| 93 |
+
def rounded_panel(base, box, radius, fill, alpha=235, border=None, bw=0):
|
| 94 |
+
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
| 95 |
+
d = ImageDraw.Draw(layer)
|
| 96 |
+
r, g, b = fill
|
| 97 |
+
d.rounded_rectangle(box, radius=radius, fill=(r, g, b, alpha),
|
| 98 |
+
outline=(border + (255,)) if border else None, width=bw)
|
| 99 |
+
base.alpha_composite(layer)
|
| 100 |
+
|
| 101 |
+
def draw_icon(base, cx, cy, r, kind, color=GOLD):
|
| 102 |
+
d = ImageDraw.Draw(base)
|
| 103 |
+
d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=color + (255,), width=max(2, r // 10))
|
| 104 |
+
s = r * 0.5
|
| 105 |
+
if kind == "check":
|
| 106 |
+
d.line([(cx - s * 0.5, cy), (cx - s * 0.05, cy + s * 0.5), (cx + s * 0.6, cy - s * 0.5)], fill=color, width=max(2, r // 8))
|
| 107 |
+
elif kind == "drop":
|
| 108 |
+
d.ellipse([cx - s * 0.5, cy - s * 0.2, cx + s * 0.5, cy + s * 0.6], fill=color)
|
| 109 |
+
d.polygon([(cx, cy - s * 0.7), (cx - s * 0.5, cy + s * 0.1), (cx + s * 0.5, cy + s * 0.1)], fill=color)
|
| 110 |
+
elif kind == "shield":
|
| 111 |
+
d.polygon([(cx, cy - s * 0.7), (cx + s * 0.6, cy - s * 0.4), (cx + s * 0.6, cy + s * 0.2), (cx, cy + s * 0.7), (cx - s * 0.6, cy + s * 0.2), (cx - s * 0.6, cy - s * 0.4)], outline=color, width=max(2, r // 9))
|
| 112 |
+
elif kind == "leaf":
|
| 113 |
+
d.pieslice([cx - s * 0.7, cy - s * 0.7, cx + s * 0.3, cy + s * 0.3], 0, 270, fill=color)
|
| 114 |
+
elif kind == "sun":
|
| 115 |
+
d.ellipse([cx - s * 0.35, cy - s * 0.35, cx + s * 0.35, cy + s * 0.35], fill=color)
|
| 116 |
+
for a in range(0, 360, 45):
|
| 117 |
+
ax, ay = math.cos(math.radians(a)), math.sin(math.radians(a))
|
| 118 |
+
d.line([(cx + ax * s * 0.55, cy + ay * s * 0.55), (cx + ax * s * 0.8, cy + ay * s * 0.8)], fill=color, width=max(2, r // 12))
|
| 119 |
+
elif kind == "sparkle":
|
| 120 |
+
d.polygon([(cx, cy - s * 0.8), (cx + s * 0.2, cy - s * 0.2), (cx + s * 0.8, cy), (cx + s * 0.2, cy + s * 0.2), (cx, cy + s * 0.8), (cx - s * 0.2, cy + s * 0.2), (cx - s * 0.8, cy), (cx - s * 0.2, cy - s * 0.2)], fill=color)
|
| 121 |
+
else:
|
| 122 |
+
d.ellipse([cx - s * 0.3, cy - s * 0.3, cx + s * 0.3, cy + s * 0.3], fill=color)
|
| 123 |
+
|
| 124 |
+
def badge(base, xy, text, font, bg=ACCENT, fg=WHITE, pad=None):
|
| 125 |
+
d = ImageDraw.Draw(base)
|
| 126 |
+
x, y = xy
|
| 127 |
+
if pad is None:
|
| 128 |
+
pad = font.size * 0.4
|
| 129 |
+
w = d.textlength(text, font=font)
|
| 130 |
+
h = font.size
|
| 131 |
+
box = [x, y, x + w + pad * 2, y + h + pad * 1.2]
|
| 132 |
+
rounded_panel(base, box, int(h * 0.35), bg, alpha=255)
|
| 133 |
+
d.text((x + pad, y + pad * 0.4), text, font=font, fill=fg)
|
| 134 |
+
|
| 135 |
+
def wrap(d, t, f, mw):
|
| 136 |
+
out = []
|
| 137 |
+
cur = ""
|
| 138 |
+
for w in t.split():
|
| 139 |
+
if d.textlength((cur + " " + w).strip(), font=f) <= mw:
|
| 140 |
+
cur = (cur + " " + w).strip()
|
| 141 |
+
else:
|
| 142 |
+
if cur:
|
| 143 |
+
out.append(cur)
|
| 144 |
+
cur = w
|
| 145 |
+
if cur:
|
| 146 |
+
out.append(cur)
|
| 147 |
+
return out
|
| 148 |
+
|
| 149 |
+
def tpl_feature_panel(img, spec):
|
| 150 |
+
W, H = img.size
|
| 151 |
+
d = ImageDraw.Draw(img)
|
| 152 |
+
lang = spec.get("lang", "vi")
|
| 153 |
+
side = spec.get("panel_side", "left")
|
| 154 |
+
pw = int(W * 0.46)
|
| 155 |
+
ph = int(H * 0.88)
|
| 156 |
+
px = int(W * 0.04) if side == "left" else W - int(W * 0.04) - pw
|
| 157 |
+
py = (H - ph) // 2
|
| 158 |
+
rounded_panel(img, [px, py, px + pw, py + ph], int(W * 0.03), (252, 250, 247), alpha=238, border=GOLD, bw=3)
|
| 159 |
+
pad = int(pw * 0.09)
|
| 160 |
+
x = px + pad
|
| 161 |
+
y = py + pad
|
| 162 |
+
tf = font_of("vn" if lang == "vi" else "sans", int(pw * 0.14), lang)
|
| 163 |
+
text_shadow(img, (x, y), spec.get("title", "").upper(), tf, DARK, blur=1)
|
| 164 |
+
y += int(pw * 0.16)
|
| 165 |
+
d.line([(x, y), (x + pw - 2 * pad, y)], fill=GOLD, width=2)
|
| 166 |
+
y += int(pw * 0.06)
|
| 167 |
+
feats = spec.get("features", [])[:8]
|
| 168 |
+
rf = font_of("vn_med" if lang == "vi" else "condensed", int(pw * 0.075), lang)
|
| 169 |
+
row_h = (ph - 2 * pad - int(pw * 0.22)) / max(len(feats), 1)
|
| 170 |
+
ir = int(row_h * 0.32)
|
| 171 |
+
for ft in feats:
|
| 172 |
+
cy = int(y + ir)
|
| 173 |
+
draw_icon(img, x + ir, cy, ir, ft.get("icon", "check"), GOLD)
|
| 174 |
+
d.text((x + ir * 2 + int(pw * 0.03), cy - rf.size * 0.55), ft.get("text", ""), font=rf, fill=DARK)
|
| 175 |
+
y += row_h
|
| 176 |
+
return img
|
| 177 |
+
|
| 178 |
+
def tpl_multicolor(img, spec):
|
| 179 |
+
W, H = img.size
|
| 180 |
+
d = ImageDraw.Draw(img)
|
| 181 |
+
lang = spec.get("lang", "en")
|
| 182 |
+
pos = spec.get("text_pos", "bottom-left")
|
| 183 |
+
words = spec.get("headline_words", [])
|
| 184 |
+
fk = "vn" if lang == "vi" else spec.get("font", "impact")
|
| 185 |
+
size = int(W * 0.11)
|
| 186 |
+
f = font_of(fk, size, lang)
|
| 187 |
+
margin = int(W * 0.05)
|
| 188 |
+
stacked = spec.get("stack", True)
|
| 189 |
+
x0 = margin if "left" in pos else W - margin
|
| 190 |
+
y = int(H * 0.55) if "bottom" in pos else margin
|
| 191 |
+
if stacked:
|
| 192 |
+
for wd in words:
|
| 193 |
+
t = wd.get("text", "")
|
| 194 |
+
c = col(wd.get("color", "white"))
|
| 195 |
+
text_shadow(img, (x0, y), t, f, c, stroke=max(1, size // 40))
|
| 196 |
+
y += int(size * 1.02)
|
| 197 |
+
sub = spec.get("sub", "")
|
| 198 |
+
if sub:
|
| 199 |
+
sf = font_of("vn_med" if lang == "vi" else "sans", int(size * 0.28), lang)
|
| 200 |
+
text_shadow(img, (x0, y + int(size * 0.05)), sub, sf, WHITE, stroke=1)
|
| 201 |
+
y += int(size * 0.4)
|
| 202 |
+
if spec.get("tag"):
|
| 203 |
+
bf = font_of("vn_bold" if lang == "vi" else "condensed", int(size * 0.3), lang)
|
| 204 |
+
badge(img, (x0, y + int(size * 0.2)), spec["tag"].upper(), bf, bg=ACCENT)
|
| 205 |
+
return img
|
| 206 |
+
|
| 207 |
+
def tpl_simple(img, spec):
|
| 208 |
+
W, H = img.size
|
| 209 |
+
d = ImageDraw.Draw(img)
|
| 210 |
+
lang = spec.get("lang", "en")
|
| 211 |
+
blocks = spec.get("text_blocks", [])
|
| 212 |
+
pos = spec.get("text_pos", "bottom-center")
|
| 213 |
+
vert = next((v for v in ("top", "center", "bottom") if v in pos), "bottom")
|
| 214 |
+
horiz = next((h for h in ("left", "right", "center") if h in pos), "center")
|
| 215 |
+
ratio = {"eyebrow": 0.030, "headline": 0.10, "subhead": 0.040, "tag": 0.034}
|
| 216 |
+
mw = int(W * 0.60)
|
| 217 |
+
R = []
|
| 218 |
+
for b in blocks:
|
| 219 |
+
role = b.get("role", "headline")
|
| 220 |
+
t = (b.get("text") or "").strip()
|
| 221 |
+
if not t:
|
| 222 |
+
continue
|
| 223 |
+
if role in ("eyebrow", "tag"):
|
| 224 |
+
t = t.upper()
|
| 225 |
+
fk = b.get("font", "script")
|
| 226 |
+
sz = max(20, int(W * ratio.get(role, 0.05)))
|
| 227 |
+
f = font_of(fk, sz, lang)
|
| 228 |
+
while d.textlength(t, font=f) > mw and sz > 18:
|
| 229 |
+
sz -= 3
|
| 230 |
+
f = font_of(fk, sz, lang)
|
| 231 |
+
if role in ("subhead", "desc") and d.textlength(t, font=f) > mw:
|
| 232 |
+
for ln in wrap(d, t, f, mw):
|
| 233 |
+
R.append((ln, f, sz, role))
|
| 234 |
+
else:
|
| 235 |
+
R.append((t, f, sz, role))
|
| 236 |
+
if not R:
|
| 237 |
+
return img
|
| 238 |
+
gap = int(W * 0.014)
|
| 239 |
+
th = sum(r[2] for r in R) + gap * (len(R) + 1)
|
| 240 |
+
bw = max(d.textlength(r[0], font=r[1]) for r in R)
|
| 241 |
+
y = int(H * 0.05) if vert == "top" else (H - th) // 2 if vert == "center" else H - int(H * 0.05) - th
|
| 242 |
+
bx = int(W * 0.05) if horiz == "left" else W - int(W * 0.05) - bw if horiz == "right" else (W - bw) // 2
|
| 243 |
+
|
| 244 |
+
def lx(w):
|
| 245 |
+
return bx if horiz == "left" else bx + bw - w if horiz == "right" else (W - w) / 2
|
| 246 |
+
|
| 247 |
+
for t, f, sz, role in R:
|
| 248 |
+
w = d.textlength(t, font=f)
|
| 249 |
+
text_shadow(img, (lx(w), y), t, f, ACCENT if role == "tag" else WHITE, stroke=max(1, sz // 48))
|
| 250 |
+
if role == "headline":
|
| 251 |
+
uy = y + sz + int(gap * 0.2)
|
| 252 |
+
ul = int(min(bw * 0.4, sz * 3))
|
| 253 |
+
ux = lx(ul) if horiz != "center" else (W - ul) // 2
|
| 254 |
+
ImageDraw.Draw(img).rectangle([ux, uy, ux + ul, uy + max(3, sz // 24)], fill=ACCENT + (255,))
|
| 255 |
+
y += int(gap * 0.5)
|
| 256 |
+
y += sz + gap
|
| 257 |
+
return img
|
| 258 |
+
|
| 259 |
+
def render(img_rgba, spec):
|
| 260 |
+
t = spec.get("template", "simple")
|
| 261 |
+
if t == "feature_panel":
|
| 262 |
+
return tpl_feature_panel(img_rgba, spec)
|
| 263 |
+
if t == "multicolor":
|
| 264 |
+
return tpl_multicolor(img_rgba, spec)
|
| 265 |
+
return tpl_simple(img_rgba, spec)
|
backend/app/services/listing_free/generator.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, json, time, threading, pathlib
|
| 2 |
+
from PIL import Image
|
| 3 |
+
from . import design
|
| 4 |
+
from .wangp import WanGPClient
|
| 5 |
+
|
| 6 |
+
_human_types = {"thumbnail", "in_use", "lifestyle", "hero"}
|
| 7 |
+
|
| 8 |
+
def generate_listing_free(session, items, product_image, model_image=None):
|
| 9 |
+
"""Generate listing images using Wangp background + Pillow design overlay.
|
| 10 |
+
|
| 11 |
+
Items are spec dicts with keys:
|
| 12 |
+
type, label, template, lang, scene_prompt,
|
| 13 |
+
and template-specific fields (title, features, headline_words, text_blocks, etc.)
|
| 14 |
+
"""
|
| 15 |
+
s = session
|
| 16 |
+
s["status"] = "generating"
|
| 17 |
+
s["progress"] = {"current": 0, "total": len(items)}
|
| 18 |
+
s["results"] = []
|
| 19 |
+
s["errors"] = []
|
| 20 |
+
|
| 21 |
+
wan = WanGPClient()
|
| 22 |
+
|
| 23 |
+
for i, item in enumerate(items):
|
| 24 |
+
s["progress"] = {"current": i + 1, "total": len(items)}
|
| 25 |
+
ptype = item.get("type", f"img_{i}")
|
| 26 |
+
lang = item.get("lang", "vi")
|
| 27 |
+
out_path = os.path.join(s["dir"], f"{ptype}_{s['id']}.jpg")
|
| 28 |
+
|
| 29 |
+
s["last_status"] = {"level": "info", "message": f"[{i+1}/{len(items)}] \"{item.get('label', ptype)}\" generating..."}
|
| 30 |
+
|
| 31 |
+
success = False
|
| 32 |
+
|
| 33 |
+
if item.get("scene_prompt"):
|
| 34 |
+
try:
|
| 35 |
+
result = wan.generate_one(item, out_path, product_image, model_image)
|
| 36 |
+
if result:
|
| 37 |
+
try:
|
| 38 |
+
img = Image.open(out_path).convert("RGBA")
|
| 39 |
+
design.render(img, item)
|
| 40 |
+
img.convert("RGB").save(out_path, quality=95)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
pass
|
| 43 |
+
success = True
|
| 44 |
+
s["last_status"] = {"level": "success", "message": f"Done \"{item.get('label', ptype)}\" with Wangp"}
|
| 45 |
+
except Exception as e:
|
| 46 |
+
s["last_status"] = {"level": "error", "message": f"Wangp error: {str(e)[:100]}"}
|
| 47 |
+
s["errors"].append({"type": ptype, "error": str(e)})
|
| 48 |
+
|
| 49 |
+
if not success:
|
| 50 |
+
bg_path = os.path.join(s["dir"], f"bg_{ptype}_{s['id']}.png")
|
| 51 |
+
bg_path_created = False
|
| 52 |
+
if product_image and os.path.exists(product_image):
|
| 53 |
+
try:
|
| 54 |
+
bg = Image.open(product_image).convert("RGBA")
|
| 55 |
+
w, h = 1088, 1088
|
| 56 |
+
bg = bg.resize((w, h), Image.LANCZOS)
|
| 57 |
+
bg.save(bg_path, "PNG")
|
| 58 |
+
bg_path_created = True
|
| 59 |
+
except Exception:
|
| 60 |
+
pass
|
| 61 |
+
if not bg_path_created:
|
| 62 |
+
try:
|
| 63 |
+
bg = Image.new("RGBA", (1088, 1088), (255, 255, 255, 255))
|
| 64 |
+
bg.save(bg_path, "PNG")
|
| 65 |
+
except Exception:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
img = Image.open(bg_path).convert("RGBA") if bg_path_created else Image.new("RGBA", (1088, 1088), (255, 255, 255, 255))
|
| 70 |
+
design.render(img, item)
|
| 71 |
+
img.convert("RGB").save(out_path, quality=95)
|
| 72 |
+
success = True
|
| 73 |
+
s["last_status"] = {"level": "success", "message": f"Done \"{item.get('label', ptype)}\" (design only)"}
|
| 74 |
+
except Exception as e:
|
| 75 |
+
s["last_status"] = {"level": "error", "message": f"Design error: {str(e)[:100]}"}
|
| 76 |
+
s["errors"].append({"type": ptype, "error": str(e)})
|
| 77 |
+
|
| 78 |
+
if success:
|
| 79 |
+
s["results"].append({
|
| 80 |
+
"type": ptype,
|
| 81 |
+
"label": item.get("label", ptype),
|
| 82 |
+
"images": [out_path],
|
| 83 |
+
"status": "success",
|
| 84 |
+
})
|
| 85 |
+
else:
|
| 86 |
+
s["results"].append({
|
| 87 |
+
"type": ptype,
|
| 88 |
+
"label": item.get("label", ptype),
|
| 89 |
+
"images": [],
|
| 90 |
+
"status": "error",
|
| 91 |
+
"error": str(s["errors"][-1]["error"]) if s["errors"] else "Unknown",
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
s["status"] = "completed"
|
| 95 |
+
s["last_status"] = {"level": "success", "message": "Completed!"}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
_sessions_global = {}
|
| 99 |
+
_sessions_lock = threading.Lock()
|
| 100 |
+
|
| 101 |
+
def _get_global_sessions():
|
| 102 |
+
return _sessions_global
|
| 103 |
+
|
| 104 |
+
def set_session(sid, session):
|
| 105 |
+
with _sessions_lock:
|
| 106 |
+
_sessions_global[sid] = session
|
| 107 |
+
|
| 108 |
+
def get_session(sid):
|
| 109 |
+
with _sessions_lock:
|
| 110 |
+
return _sessions_global.get(sid)
|
| 111 |
+
|
| 112 |
+
def del_session(sid):
|
| 113 |
+
with _sessions_lock:
|
| 114 |
+
_sessions_global.pop(sid, None)
|
backend/app/services/listing_free/wangp.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time, os, re, httpx, shutil
|
| 2 |
+
|
| 3 |
+
try:
|
| 4 |
+
from gradio_client import Client, handle_file
|
| 5 |
+
HAS_GRADIO = True
|
| 6 |
+
except ImportError:
|
| 7 |
+
Client = None
|
| 8 |
+
handle_file = None
|
| 9 |
+
HAS_GRADIO = False
|
| 10 |
+
|
| 11 |
+
WANGP_URL = "https://fe91b5c9d1cfdfc08e.gradio.live/"
|
| 12 |
+
DEFAULT_MODEL = "flux2_klein_9b"
|
| 13 |
+
RESOLUTION = "1088x1088"
|
| 14 |
+
STEPS = 20
|
| 15 |
+
GUIDANCE = 4
|
| 16 |
+
REF_STRENGTH = 75
|
| 17 |
+
NEGATIVE_PROMPT = "mirror, reflection in mirror, standing in front of mirror, bathroom mirror, text, words, letters, typography, captions, deformed hands, extra fingers, bad anatomy, blurry, low quality, watermark"
|
| 18 |
+
HUMAN_TYPES = {"thumbnail", "in_use", "lifestyle", "hero"}
|
| 19 |
+
BANNED_SCENE_WORDS = ["text", "typography", "overlay", "caption", "headline", "banner", "label",
|
| 20 |
+
"words", "letters", "writing", "sign", "title", "slogan", "font"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class WanGPClient:
|
| 24 |
+
def __init__(self, url=WANGP_URL, model=DEFAULT_MODEL):
|
| 25 |
+
self.url = url
|
| 26 |
+
self.model = model
|
| 27 |
+
self.client = None
|
| 28 |
+
|
| 29 |
+
def ensure_client(self):
|
| 30 |
+
if not HAS_GRADIO:
|
| 31 |
+
raise RuntimeError("gradio-client not installed. Run: pip install gradio-client")
|
| 32 |
+
if self.client is None:
|
| 33 |
+
self.client = Client(self.url)
|
| 34 |
+
return self.client
|
| 35 |
+
|
| 36 |
+
def clean_scene(self, p):
|
| 37 |
+
segs = re.split(r'[,.]', p)
|
| 38 |
+
kept = []
|
| 39 |
+
for seg in segs:
|
| 40 |
+
low = seg.lower()
|
| 41 |
+
if any(re.search(r'\b' + w + r'\b', low) for w in BANNED_SCENE_WORDS):
|
| 42 |
+
continue
|
| 43 |
+
if seg.strip():
|
| 44 |
+
kept.append(seg.strip())
|
| 45 |
+
out = ", ".join(kept)
|
| 46 |
+
return (out + ".") if out else p
|
| 47 |
+
|
| 48 |
+
def save_inputs_kwargs(self, prompt, refs):
|
| 49 |
+
vpt = "I" if refs else ""
|
| 50 |
+
return dict(
|
| 51 |
+
prompt=prompt, negative_prompt=NEGATIVE_PROMPT, resolution=RESOLUTION, seed=-1,
|
| 52 |
+
num_inference_steps=STEPS, guidance_scale=GUIDANCE, batch_size=1, image_refs=refs,
|
| 53 |
+
image_refs_relative_size=REF_STRENGTH, video_prompt_type=vpt, image_prompt_type="",
|
| 54 |
+
control_net_weight=1, denoising_strength=1,
|
| 55 |
+
image_mask_guide={"background": None, "layers": [], "composite": None}, model_mode=None,
|
| 56 |
+
video_source=None, video_guide=None, image_guide=None, video_mask=None, image_mask=None,
|
| 57 |
+
audio_guide=None, audio_guide2=None, custom_guide=None, audio_source=None,
|
| 58 |
+
replace_voice_sample=None, replace_voice_sample2=None,
|
| 59 |
+
custom_setting_dropdown_1=None, custom_setting_dropdown_2=None,
|
| 60 |
+
custom_setting_dropdown_3=None, custom_setting_dropdown_4=None,
|
| 61 |
+
custom_setting_dropdown_5=None, api_name="/save_inputs_1"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def img_paths(self, result):
|
| 65 |
+
out = []
|
| 66 |
+
def w(o):
|
| 67 |
+
if isinstance(o, dict):
|
| 68 |
+
for k in ("path", "url"):
|
| 69 |
+
v = o.get(k)
|
| 70 |
+
if isinstance(v, str) and v:
|
| 71 |
+
out.append(v)
|
| 72 |
+
for v in o.values():
|
| 73 |
+
w(v)
|
| 74 |
+
elif isinstance(o, (list, tuple)):
|
| 75 |
+
for v in o:
|
| 76 |
+
w(v)
|
| 77 |
+
elif isinstance(o, str) and o.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
|
| 78 |
+
out.append(o)
|
| 79 |
+
w(result)
|
| 80 |
+
seen, unique = set(), []
|
| 81 |
+
for x in out:
|
| 82 |
+
if x not in seen:
|
| 83 |
+
seen.add(x)
|
| 84 |
+
unique.append(x)
|
| 85 |
+
return unique
|
| 86 |
+
|
| 87 |
+
def download(self, pu, dest):
|
| 88 |
+
if str(pu).startswith("http"):
|
| 89 |
+
r = httpx.get(pu, timeout=180)
|
| 90 |
+
r.raise_for_status()
|
| 91 |
+
dest.write_bytes(r.content)
|
| 92 |
+
return True
|
| 93 |
+
try:
|
| 94 |
+
r = httpx.get(f"{self.url.rstrip('/')}/gradio_api/file={pu}", timeout=180)
|
| 95 |
+
if r.status_code == 200 and r.content:
|
| 96 |
+
dest.write_bytes(r.content)
|
| 97 |
+
return True
|
| 98 |
+
except Exception:
|
| 99 |
+
pass
|
| 100 |
+
src = os.path.abspath(pu)
|
| 101 |
+
if os.path.exists(src):
|
| 102 |
+
shutil.copy(src, dest)
|
| 103 |
+
return True
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
def _gen_calls(self, item, product_refs, person_refs):
|
| 107 |
+
wan = self.ensure_client()
|
| 108 |
+
with_person = item.get("type", "") in HUMAN_TYPES
|
| 109 |
+
refs = product_refs[:]
|
| 110 |
+
if with_person and person_refs:
|
| 111 |
+
refs.extend(person_refs)
|
| 112 |
+
wan.predict(**self.save_inputs_kwargs(self.clean_scene(item["scene_prompt"]), refs))
|
| 113 |
+
wan.predict(current_gallery_tab=0, model_choice=self.model, api_name="/process_prompt_and_add_tasks")
|
| 114 |
+
wan.predict(api_name="/process_tasks")
|
| 115 |
+
return wan.predict(api_name="/finalize_generation")
|
| 116 |
+
|
| 117 |
+
def generate_one(self, item, out_path, product_image, model_image=None):
|
| 118 |
+
product_refs = [{"image": handle_file(product_image)}] if product_image else []
|
| 119 |
+
person_refs = [{"image": handle_file(model_image)}] if model_image else []
|
| 120 |
+
|
| 121 |
+
res = None
|
| 122 |
+
for attempt in range(2):
|
| 123 |
+
try:
|
| 124 |
+
res = self._gen_calls(item, product_refs, person_refs)
|
| 125 |
+
break
|
| 126 |
+
except Exception as e:
|
| 127 |
+
msg = str(e)
|
| 128 |
+
if "not in the list of choices" in msg:
|
| 129 |
+
raise
|
| 130 |
+
if attempt == 0 and ("timed out" in msg or "Timeout" in msg):
|
| 131 |
+
time.sleep(3)
|
| 132 |
+
continue
|
| 133 |
+
raise
|
| 134 |
+
|
| 135 |
+
ps = self.img_paths(res)
|
| 136 |
+
if not ps:
|
| 137 |
+
return False
|
| 138 |
+
return self.download(ps[-1], out_path)
|
frontend/src/App.jsx
CHANGED
|
@@ -9,6 +9,7 @@ import Guide from "./pages/Guide";
|
|
| 9 |
import Support from "./pages/Support";
|
| 10 |
import Placeholder from "./pages/Placeholder";
|
| 11 |
import ListingImage from "./pages/ListingImage";
|
|
|
|
| 12 |
import Flux2Gen from "./pages/Flux2Gen";
|
| 13 |
import CreateVideo from "./pages/CreateVideo";
|
| 14 |
import AdminUsers from "./pages/AdminUsers";
|
|
@@ -29,6 +30,7 @@ const router = createBrowserRouter([
|
|
| 29 |
{ path: "/xao", element: <ShuffleVideo /> },
|
| 30 |
{ path: "/dub", element: <DubVideo /> },
|
| 31 |
{ path: "/listing-anh", element: <ListingImage /> },
|
|
|
|
| 32 |
{ path: "/flux2", element: <Flux2Gen /> },
|
| 33 |
{ path: "/tao-video", element: <CreateVideo /> },
|
| 34 |
{ path: "/huong-dan", element: <Guide /> },
|
|
|
|
| 9 |
import Support from "./pages/Support";
|
| 10 |
import Placeholder from "./pages/Placeholder";
|
| 11 |
import ListingImage from "./pages/ListingImage";
|
| 12 |
+
import ListingFree from "./pages/ListingFree";
|
| 13 |
import Flux2Gen from "./pages/Flux2Gen";
|
| 14 |
import CreateVideo from "./pages/CreateVideo";
|
| 15 |
import AdminUsers from "./pages/AdminUsers";
|
|
|
|
| 30 |
{ path: "/xao", element: <ShuffleVideo /> },
|
| 31 |
{ path: "/dub", element: <DubVideo /> },
|
| 32 |
{ path: "/listing-anh", element: <ListingImage /> },
|
| 33 |
+
{ path: "/listing-free", element: <ListingFree /> },
|
| 34 |
{ path: "/flux2", element: <Flux2Gen /> },
|
| 35 |
{ path: "/tao-video", element: <CreateVideo /> },
|
| 36 |
{ path: "/huong-dan", element: <Guide /> },
|
frontend/src/components/Sidebar.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { NavLink } from "react-router-dom";
|
| 2 |
import {
|
| 3 |
IcHome, IcDownload, IcImage, IcVideo, IcScissors, IcShuffle, IcMic,
|
| 4 |
-
IcBook, IcHelp, IcLock, IcHistory,
|
| 5 |
} from "./Icons";
|
| 6 |
|
| 7 |
const item = (to, Icon, label) => (
|
|
@@ -26,6 +26,7 @@ export default function Sidebar() {
|
|
| 26 |
<div className="label">Tạo ảnh / video</div>
|
| 27 |
{item("/down-list", IcDownload, "Down list video")}
|
| 28 |
{item("/listing-anh", IcImage, "Listing ảnh")}
|
|
|
|
| 29 |
{item("/tao-video", IcVideo, "Tạo video")}
|
| 30 |
</div>
|
| 31 |
|
|
|
|
| 1 |
import { NavLink } from "react-router-dom";
|
| 2 |
import {
|
| 3 |
IcHome, IcDownload, IcImage, IcVideo, IcScissors, IcShuffle, IcMic,
|
| 4 |
+
IcBook, IcHelp, IcLock, IcHistory, IcStar,
|
| 5 |
} from "./Icons";
|
| 6 |
|
| 7 |
const item = (to, Icon, label) => (
|
|
|
|
| 26 |
<div className="label">Tạo ảnh / video</div>
|
| 27 |
{item("/down-list", IcDownload, "Down list video")}
|
| 28 |
{item("/listing-anh", IcImage, "Listing ảnh")}
|
| 29 |
+
{item("/listing-free", IcStar, "Listing ảnh Free")}
|
| 30 |
{item("/tao-video", IcVideo, "Tạo video")}
|
| 31 |
</div>
|
| 32 |
|
frontend/src/pages/ListingFree.jsx
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect, useCallback } from "react";
|
| 2 |
+
import { IcImage, IcUpload, IcDownload, IcRefresh, IcPlus, IcStar, IcSliders } from "../components/Icons";
|
| 3 |
+
|
| 4 |
+
const TEMPLATES = [
|
| 5 |
+
{ id: "simple", name: "Simple Poster", desc: "Eyebrow + headline + subhead + tag — classic elegance" },
|
| 6 |
+
{ id: "multicolor", name: "Multicolor Headline", desc: "Stacked words with per-word colors + badge" },
|
| 7 |
+
{ id: "feature_panel", name: "Feature Panel", desc: "Rounded side panel with title + icon rows" },
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
const ICON_OPTIONS = ["check", "leaf", "drop", "sun", "shield", "sparkle"];
|
| 11 |
+
const FONT_OPTIONS = ["script", "script_bold", "handwrite", "calligraphy", "retro", "serif", "serif_classic", "serif_luxury", "sans", "condensed", "impact", "vn", "vn_bold", "vn_med"];
|
| 12 |
+
|
| 13 |
+
function newItem(type) {
|
| 14 |
+
const base = { type, label: type, lang: "vi", template: "simple", text_pos: "bottom-center", text_blocks: [{ role: "headline", text: "NEW PRODUCT", font: "sans" }] };
|
| 15 |
+
if (type === "feature_panel") {
|
| 16 |
+
base.template = "feature_panel";
|
| 17 |
+
base.panel_side = "left";
|
| 18 |
+
base.title = "FEATURES";
|
| 19 |
+
base.features = [{ icon: "check", text: "Feature 1" }];
|
| 20 |
+
} else if (type === "multicolor") {
|
| 21 |
+
base.template = "multicolor";
|
| 22 |
+
base.font = "impact";
|
| 23 |
+
base.headline_words = [{ text: "BOLD", color: "white" }];
|
| 24 |
+
base.sub = "";
|
| 25 |
+
base.tag = "";
|
| 26 |
+
}
|
| 27 |
+
return base;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const PRESET_ITEMS = [
|
| 31 |
+
{ type: "thumbnail", label: "Thumbnail" },
|
| 32 |
+
{ type: "hero", label: "Hero" },
|
| 33 |
+
{ type: "feature", label: "Feature" },
|
| 34 |
+
{ type: "benefit", label: "Benefit" },
|
| 35 |
+
{ type: "ingredient", label: "Ingredient" },
|
| 36 |
+
{ type: "display", label: "Display" },
|
| 37 |
+
{ type: "promo", label: "Promo" },
|
| 38 |
+
];
|
| 39 |
+
|
| 40 |
+
export default function ListingFree() {
|
| 41 |
+
const [sessionId, setSessionId] = useState(null);
|
| 42 |
+
const [productFile, setProductFile] = useState(null);
|
| 43 |
+
const [productPreview, setProductPreview] = useState(null);
|
| 44 |
+
const [bgFile, setBgFile] = useState(null);
|
| 45 |
+
const [bgPreview, setBgPreview] = useState(null);
|
| 46 |
+
const [items, setItems] = useState([]);
|
| 47 |
+
const [results, setResults] = useState([]);
|
| 48 |
+
const [status, setStatus] = useState(null);
|
| 49 |
+
const [logs, setLogs] = useState([]);
|
| 50 |
+
const [progress, setProgress] = useState(0);
|
| 51 |
+
const [busy, setBusy] = useState(false);
|
| 52 |
+
const [generating, setGenerating] = useState(false);
|
| 53 |
+
const [showLog, setShowLog] = useState(false);
|
| 54 |
+
const pollRef = useRef(null);
|
| 55 |
+
const prodRef = useRef(null);
|
| 56 |
+
const bgRef = useRef(null);
|
| 57 |
+
const lastMsgRef = useRef("");
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
return () => {
|
| 61 |
+
if (productPreview) URL.revokeObjectURL(productPreview);
|
| 62 |
+
if (bgPreview) URL.revokeObjectURL(bgPreview);
|
| 63 |
+
if (pollRef.current) clearTimeout(pollRef.current);
|
| 64 |
+
};
|
| 65 |
+
}, [productPreview, bgPreview]);
|
| 66 |
+
|
| 67 |
+
const addLog = (level, msg) => {
|
| 68 |
+
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
| 69 |
+
setShowLog(true);
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handleProductFile = (f) => {
|
| 73 |
+
if (!f || !f.type.startsWith("image/")) return;
|
| 74 |
+
if (productPreview) URL.revokeObjectURL(productPreview);
|
| 75 |
+
setProductFile(f);
|
| 76 |
+
setProductPreview(URL.createObjectURL(f));
|
| 77 |
+
setSessionId(null); setResults([]); setItems([]); setLogs([]);
|
| 78 |
+
setProgress(0); setStatus(null); setBusy(false); setGenerating(false);
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const handleBgFile = (f) => {
|
| 82 |
+
if (!f || !f.type.startsWith("image/")) return;
|
| 83 |
+
if (bgPreview) URL.revokeObjectURL(bgPreview);
|
| 84 |
+
setBgFile(f);
|
| 85 |
+
setBgPreview(URL.createObjectURL(f));
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const addPresetItem = (preset) => {
|
| 89 |
+
setItems(prev => [...prev, { ...newItem(preset.type), label: preset.label }]);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const removeItem = (idx) => {
|
| 93 |
+
setItems(prev => prev.filter((_, i) => i !== idx));
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const updateItem = (idx, patch) => {
|
| 97 |
+
setItems(prev => prev.map((it, i) => i === idx ? { ...it, ...patch } : it));
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const startUpload = async () => {
|
| 101 |
+
if (!productFile || busy || generating) return;
|
| 102 |
+
setBusy(true); setStatus("Uploading...");
|
| 103 |
+
addLog("info", "Uploading product image...");
|
| 104 |
+
|
| 105 |
+
const fd = new FormData();
|
| 106 |
+
fd.append("product", productFile);
|
| 107 |
+
if (bgFile) fd.append("background", bgFile);
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
const res = await fetch("/api/listing-free/upload", { method: "POST", body: fd });
|
| 111 |
+
const data = await res.json();
|
| 112 |
+
if (!data.ok && data.error) throw new Error(data.error);
|
| 113 |
+
setSessionId(data.session_id);
|
| 114 |
+
addLog("success", "Uploaded successfully");
|
| 115 |
+
setBusy(false);
|
| 116 |
+
} catch (e) {
|
| 117 |
+
setStatus("Error: " + e.message);
|
| 118 |
+
addLog("error", e.message);
|
| 119 |
+
setBusy(false);
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const startDesign = async () => {
|
| 124 |
+
if (!sessionId || items.length === 0 || busy || generating) return;
|
| 125 |
+
setGenerating(true); setStatus("Applying designs...");
|
| 126 |
+
setResults([]); setLogs([]);
|
| 127 |
+
addLog("info", `Applying ${items.length} designs...`);
|
| 128 |
+
|
| 129 |
+
try {
|
| 130 |
+
const res = await fetch("/api/listing-free/design", {
|
| 131 |
+
method: "POST",
|
| 132 |
+
headers: { "Content-Type": "application/json" },
|
| 133 |
+
body: JSON.stringify({ session_id: sessionId, items }),
|
| 134 |
+
});
|
| 135 |
+
const data = await res.json();
|
| 136 |
+
if (!data.ok && data.error) throw new Error(data.error);
|
| 137 |
+
await pollSession();
|
| 138 |
+
} catch (e) {
|
| 139 |
+
setStatus("Error: " + e.message);
|
| 140 |
+
addLog("error", e.message);
|
| 141 |
+
} finally {
|
| 142 |
+
setGenerating(false);
|
| 143 |
+
}
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
const pollSession = () => new Promise((resolve, reject) => {
|
| 147 |
+
const poll = async () => {
|
| 148 |
+
try {
|
| 149 |
+
const res = await fetch(`/api/listing-free/session/${sessionId}`);
|
| 150 |
+
if (!res.ok) { pollRef.current = setTimeout(poll, 1000); return; }
|
| 151 |
+
const s = await res.json();
|
| 152 |
+
|
| 153 |
+
if (s.last_status) {
|
| 154 |
+
const { level, message } = s.last_status;
|
| 155 |
+
setStatus(message);
|
| 156 |
+
if (message !== lastMsgRef.current) {
|
| 157 |
+
lastMsgRef.current = message;
|
| 158 |
+
if (level === "error") addLog("error", message);
|
| 159 |
+
else if (level === "success") addLog("success", message);
|
| 160 |
+
else addLog("info", message);
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
if (s.progress) {
|
| 164 |
+
setProgress(s.progress.total > 0 ? Math.round((s.progress.current / s.progress.total) * 100) : 0);
|
| 165 |
+
}
|
| 166 |
+
if (s.status === "completed") {
|
| 167 |
+
setResults(s.results || []);
|
| 168 |
+
setStatus("Completed!");
|
| 169 |
+
resolve();
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
if (s.status === "error") {
|
| 173 |
+
reject(new Error(s.errors?.[0]?.error || "Unknown error"));
|
| 174 |
+
return;
|
| 175 |
+
}
|
| 176 |
+
pollRef.current = setTimeout(poll, 1000);
|
| 177 |
+
} catch (e) {
|
| 178 |
+
pollRef.current = setTimeout(poll, 1000);
|
| 179 |
+
}
|
| 180 |
+
};
|
| 181 |
+
setTimeout(poll, 500);
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
const downloadAll = async () => {
|
| 185 |
+
if (!sessionId) return;
|
| 186 |
+
try {
|
| 187 |
+
const res = await fetch(`/api/listing-free/download/${sessionId}`);
|
| 188 |
+
if (!res.ok) throw new Error("Download error");
|
| 189 |
+
const blob = await res.blob();
|
| 190 |
+
const url = URL.createObjectURL(blob);
|
| 191 |
+
const a = document.createElement("a");
|
| 192 |
+
a.href = url; a.download = `listing_free_${sessionId.slice(0, 8)}.zip`;
|
| 193 |
+
a.click(); URL.revokeObjectURL(url);
|
| 194 |
+
} catch (e) { addLog("error", e.message); }
|
| 195 |
+
};
|
| 196 |
+
|
| 197 |
+
const resetAll = () => {
|
| 198 |
+
if (pollRef.current) clearTimeout(pollRef.current);
|
| 199 |
+
if (sessionId) fetch(`/api/listing-free/session/${sessionId}`, { method: "DELETE" }).catch(() => {});
|
| 200 |
+
setSessionId(null); setResults([]); setItems([]); setLogs([]);
|
| 201 |
+
setProgress(0); setStatus(null); setBusy(false); setGenerating(false);
|
| 202 |
+
if (productPreview) { URL.revokeObjectURL(productPreview); setProductPreview(null); }
|
| 203 |
+
if (bgPreview) { URL.revokeObjectURL(bgPreview); setBgPreview(null); }
|
| 204 |
+
setProductFile(null); setBgFile(null);
|
| 205 |
+
setShowLog(false);
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
return (
|
| 209 |
+
<div>
|
| 210 |
+
<div className="h1">Workspace: <span className="accent">Listing ảnh Free</span></div>
|
| 211 |
+
<p className="subtitle">Tạo ảnh listing miễn phí với thiết kế đa lớp — feature panel, multicolor headline, poster đơn giản. Upload ảnh sản phẩm, chọn mẫu, tuỳ chỉnh text, và tải về.</p>
|
| 212 |
+
|
| 213 |
+
<div className="grid-2">
|
| 214 |
+
<div className="stack">
|
| 215 |
+
|
| 216 |
+
{/* Upload */}
|
| 217 |
+
<div className="card">
|
| 218 |
+
<div className="panel-title"><IcUpload size={16} /> Upload ảnh</div>
|
| 219 |
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
|
| 220 |
+
<div className="dropzone" style={{ cursor: "pointer", padding: 8, display: "flex", flexDirection: "column" }}
|
| 221 |
+
onClick={() => prodRef.current?.click()}>
|
| 222 |
+
<input ref={prodRef} type="file" accept="image/jpeg,image/png,image/webp" style={{ display: "none" }}
|
| 223 |
+
onChange={(e) => handleProductFile(e.target.files?.[0])} />
|
| 224 |
+
{productPreview ? (
|
| 225 |
+
<div>
|
| 226 |
+
<img src={productPreview} alt="" style={{ width: "100%", aspectRatio: "1/1", borderRadius: 8, objectFit: "cover" }} />
|
| 227 |
+
<div style={{ fontSize: 11, textAlign: "center", marginTop: 4, color: "var(--muted)" }}>Product</div>
|
| 228 |
+
</div>
|
| 229 |
+
) : (
|
| 230 |
+
<div style={{ textAlign: "center", padding: "24px 4px", flex: 1, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
| 231 |
+
<div className="chip" style={{ margin: "0 auto 6px" }}><IcImage size={18} /></div>
|
| 232 |
+
<div style={{ fontSize: 11, fontWeight: 600 }}>Product image</div>
|
| 233 |
+
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 2 }}>Required</div>
|
| 234 |
+
</div>
|
| 235 |
+
)}
|
| 236 |
+
</div>
|
| 237 |
+
<div className="dropzone" style={{ cursor: "pointer", padding: 8, display: "flex", flexDirection: "column" }}
|
| 238 |
+
onClick={() => bgRef.current?.click()}>
|
| 239 |
+
<input ref={bgRef} type="file" accept="image/jpeg,image/png,image/webp" style={{ display: "none" }}
|
| 240 |
+
onChange={(e) => handleBgFile(e.target.files?.[0])} />
|
| 241 |
+
{bgPreview ? (
|
| 242 |
+
<div>
|
| 243 |
+
<img src={bgPreview} alt="" style={{ width: "100%", aspectRatio: "1/1", borderRadius: 8, objectFit: "cover" }} />
|
| 244 |
+
<div style={{ fontSize: 11, textAlign: "center", marginTop: 4, color: "var(--muted)" }}>Background</div>
|
| 245 |
+
</div>
|
| 246 |
+
) : (
|
| 247 |
+
<div style={{ textAlign: "center", padding: "24px 4px", flex: 1, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
| 248 |
+
<div className="chip" style={{ margin: "0 auto 6px" }}><IcImage size={18} /></div>
|
| 249 |
+
<div style={{ fontSize: 11, fontWeight: 600 }}>Background</div>
|
| 250 |
+
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 2 }}>Optional</div>
|
| 251 |
+
</div>
|
| 252 |
+
)}
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
{productPreview && !sessionId && (
|
| 256 |
+
<button className="btn primary" style={{ marginTop: 8, width: "100%" }} disabled={busy} onClick={startUpload}>
|
| 257 |
+
{busy ? "Uploading..." : "Upload & Start"}
|
| 258 |
+
</button>
|
| 259 |
+
)}
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
{/* Add presets */}
|
| 263 |
+
{sessionId && (
|
| 264 |
+
<div className="card">
|
| 265 |
+
<div className="panel-title"><IcPlus size={16} /> Add listing items</div>
|
| 266 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 12 }}>
|
| 267 |
+
{PRESET_ITEMS.map(p => (
|
| 268 |
+
<div key={p.type} onClick={() => addPresetItem(p)}
|
| 269 |
+
style={{ padding: "6px 14px", fontSize: 12, borderRadius: 20, cursor: "pointer",
|
| 270 |
+
border: "2px solid var(--line)", background: "var(--white)", userSelect: "none" }}>
|
| 271 |
+
+ {p.label}
|
| 272 |
+
</div>
|
| 273 |
+
))}
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
{items.length > 0 && items.map((item, idx) => (
|
| 277 |
+
<div key={idx} style={{ border: "1px solid var(--line)", borderRadius: "var(--radius-sm)", padding: 12, marginBottom: 10, background: "var(--cream)" }}>
|
| 278 |
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
| 279 |
+
<b style={{ fontSize: 13 }}>{item.label}</b>
|
| 280 |
+
<button className="btn sm" onClick={() => removeItem(idx)} style={{ fontSize: 11, color: "var(--red)", padding: "2px 8px" }}>Remove</button>
|
| 281 |
+
</div>
|
| 282 |
+
|
| 283 |
+
<div className="row" style={{ gap: 6, marginBottom: 6 }}>
|
| 284 |
+
<div style={{ flex: 1 }}>
|
| 285 |
+
<label style={{ fontSize: 11, color: "var(--muted)" }}>Template</label>
|
| 286 |
+
<select value={item.template} onChange={e => updateItem(idx, { template: e.target.value })}
|
| 287 |
+
style={{ width: "100%", fontSize: 12, padding: "4px 6px" }}>
|
| 288 |
+
{TEMPLATES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
| 289 |
+
</select>
|
| 290 |
+
</div>
|
| 291 |
+
<div style={{ flex: 1 }}>
|
| 292 |
+
<label style={{ fontSize: 11, color: "var(--muted)" }}>Language</label>
|
| 293 |
+
<select value={item.lang} onChange={e => updateItem(idx, { lang: e.target.value })}
|
| 294 |
+
style={{ width: "100%", fontSize: 12, padding: "4px 6px" }}>
|
| 295 |
+
<option value="vi">Tiếng Việt</option>
|
| 296 |
+
<option value="en">English</option>
|
| 297 |
+
</select>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
{item.template === "simple" && (
|
| 302 |
+
<div>
|
| 303 |
+
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
|
| 304 |
+
<div style={{ flex: 1 }}>
|
| 305 |
+
<label style={{ fontSize: 11, color: "var(--muted)" }}>Position</label>
|
| 306 |
+
<select value={item.text_pos} onChange={e => updateItem(idx, { text_pos: e.target.value })}
|
| 307 |
+
style={{ width: "100%", fontSize: 12, padding: "4px 6px" }}>
|
| 308 |
+
{["bottom-left","bottom-center","bottom-right","center","top-left","top-center","top-right"].map(p =>
|
| 309 |
+
<option key={p} value={p}>{p}</option>)}
|
| 310 |
+
</select>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>Text blocks</div>
|
| 314 |
+
{(item.text_blocks || []).map((tb, bi) => (
|
| 315 |
+
<div key={bi} style={{ display: "flex", gap: 4, marginBottom: 4, alignItems: "center" }}>
|
| 316 |
+
<select value={tb.role} onChange={e => {
|
| 317 |
+
const blocks = [...(item.text_blocks || [])];
|
| 318 |
+
blocks[bi] = { ...blocks[bi], role: e.target.value };
|
| 319 |
+
updateItem(idx, { text_blocks: blocks });
|
| 320 |
+
}} style={{ fontSize: 11, padding: "3px 4px", width: 80 }}>
|
| 321 |
+
{["eyebrow","headline","subhead","tag"].map(r => <option key={r} value={r}>{r}</option>)}
|
| 322 |
+
</select>
|
| 323 |
+
<input value={tb.text} onChange={e => {
|
| 324 |
+
const blocks = [...(item.text_blocks || [])];
|
| 325 |
+
blocks[bi] = { ...blocks[bi], text: e.target.value };
|
| 326 |
+
updateItem(idx, { text_blocks: blocks });
|
| 327 |
+
}} placeholder="Text" style={{ flex: 1, fontSize: 12, padding: "3px 6px" }} />
|
| 328 |
+
<select value={tb.font || "sans"} onChange={e => {
|
| 329 |
+
const blocks = [...(item.text_blocks || [])];
|
| 330 |
+
blocks[bi] = { ...blocks[bi], font: e.target.value };
|
| 331 |
+
updateItem(idx, { text_blocks: blocks });
|
| 332 |
+
}} style={{ fontSize: 11, padding: "3px 4px", width: 70 }}>
|
| 333 |
+
{FONT_OPTIONS.map(f => <option key={f} value={f}>{f}</option>)}
|
| 334 |
+
</select>
|
| 335 |
+
<button className="btn sm" style={{ fontSize: 10, padding: "2px 6px" }}
|
| 336 |
+
onClick={() => {
|
| 337 |
+
const blocks = (item.text_blocks || []).filter((_, i) => i !== bi);
|
| 338 |
+
updateItem(idx, { text_blocks: blocks });
|
| 339 |
+
}}>×</button>
|
| 340 |
+
</div>
|
| 341 |
+
))}
|
| 342 |
+
<button className="btn sm" style={{ fontSize: 11, marginTop: 4 }}
|
| 343 |
+
onClick={() => updateItem(idx, { text_blocks: [...(item.text_blocks || []), { role: "subhead", text: "", font: "sans" }] })}>
|
| 344 |
+
+ Add block
|
| 345 |
+
</button>
|
| 346 |
+
</div>
|
| 347 |
+
)}
|
| 348 |
+
|
| 349 |
+
{item.template === "multicolor" && (
|
| 350 |
+
<div>
|
| 351 |
+
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
|
| 352 |
+
<div style={{ flex: 1 }}>
|
| 353 |
+
<label style={{ fontSize: 11, color: "var(--muted)" }}>Position</label>
|
| 354 |
+
<select value={item.text_pos} onChange={e => updateItem(idx, { text_pos: e.target.value })}
|
| 355 |
+
style={{ width: "100%", fontSize: 12, padding: "4px 6px" }}>
|
| 356 |
+
<option value="bottom-left">Bottom left</option>
|
| 357 |
+
<option value="top-left">Top left</option>
|
| 358 |
+
</select>
|
| 359 |
+
</div>
|
| 360 |
+
<div style={{ flex: 1 }}>
|
| 361 |
+
<label style={{ fontSize: 11, color: "var(--muted)" }}>Font</label>
|
| 362 |
+
<select value={item.font} onChange={e => updateItem(idx, { font: e.target.value })}
|
| 363 |
+
style={{ width: "100%", fontSize: 12, padding: "4px 6px" }}>
|
| 364 |
+
{FONT_OPTIONS.map(f => <option key={f} value={f}>{f}</option>)}
|
| 365 |
+
</select>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>Words</div>
|
| 369 |
+
{(item.headline_words || []).map((w, wi) => (
|
| 370 |
+
<div key={wi} style={{ display: "flex", gap: 4, marginBottom: 4 }}>
|
| 371 |
+
<input value={w.text} onChange={e => {
|
| 372 |
+
const words = [...(item.headline_words || [])];
|
| 373 |
+
words[wi] = { ...words[wi], text: e.target.value };
|
| 374 |
+
updateItem(idx, { headline_words: words });
|
| 375 |
+
}} placeholder="Word" style={{ flex: 1, fontSize: 12, padding: "3px 6px" }} />
|
| 376 |
+
<select value={w.color} onChange={e => {
|
| 377 |
+
const words = [...(item.headline_words || [])];
|
| 378 |
+
words[wi] = { ...words[wi], color: e.target.value };
|
| 379 |
+
updateItem(idx, { headline_words: words });
|
| 380 |
+
}} style={{ fontSize: 11, padding: "3px 4px", width: 70 }}>
|
| 381 |
+
{["white","pink","gold","dark","black","red","green"].map(c => <option key={c} value={c}>{c}</option>)}
|
| 382 |
+
</select>
|
| 383 |
+
<button className="btn sm" style={{ fontSize: 10, padding: "2px 6px" }}
|
| 384 |
+
onClick={() => {
|
| 385 |
+
const words = (item.headline_words || []).filter((_, i) => i !== wi);
|
| 386 |
+
updateItem(idx, { headline_words: words });
|
| 387 |
+
}}>×</button>
|
| 388 |
+
</div>
|
| 389 |
+
))}
|
| 390 |
+
<button className="btn sm" style={{ fontSize: 11, marginTop: 4 }}
|
| 391 |
+
onClick={() => updateItem(idx, { headline_words: [...(item.headline_words || []), { text: "", color: "white" }] })}>
|
| 392 |
+
+ Add word
|
| 393 |
+
</button>
|
| 394 |
+
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
| 395 |
+
<input value={item.sub || ""} onChange={e => updateItem(idx, { sub: e.target.value })}
|
| 396 |
+
placeholder="Subtitle" style={{ flex: 1, fontSize: 12, padding: "3px 6px" }} />
|
| 397 |
+
<input value={item.tag || ""} onChange={e => updateItem(idx, { tag: e.target.value })}
|
| 398 |
+
placeholder="Tag" style={{ flex: 1, fontSize: 12, padding: "3px 6px" }} />
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
)}
|
| 402 |
+
|
| 403 |
+
{item.template === "feature_panel" && (
|
| 404 |
+
<div>
|
| 405 |
+
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
|
| 406 |
+
<div style={{ flex: 1 }}>
|
| 407 |
+
<label style={{ fontSize: 11, color: "var(--muted)" }}>Panel side</label>
|
| 408 |
+
<select value={item.panel_side} onChange={e => updateItem(idx, { panel_side: e.target.value })}
|
| 409 |
+
style={{ width: "100%", fontSize: 12, padding: "4px 6px" }}>
|
| 410 |
+
<option value="left">Left</option>
|
| 411 |
+
<option value="right">Right</option>
|
| 412 |
+
</select>
|
| 413 |
+
</div>
|
| 414 |
+
<div style={{ flex: 1 }}>
|
| 415 |
+
<label style={{ fontSize: 11, color: "var(--muted)" }}>Title</label>
|
| 416 |
+
<input value={item.title || ""} onChange={e => updateItem(idx, { title: e.target.value })}
|
| 417 |
+
placeholder="Panel title" style={{ width: "100%", fontSize: 12, padding: "4px 6px" }} />
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>Features</div>
|
| 421 |
+
{(item.features || []).map((ft, fi) => (
|
| 422 |
+
<div key={fi} style={{ display: "flex", gap: 4, marginBottom: 4 }}>
|
| 423 |
+
<select value={ft.icon} onChange={e => {
|
| 424 |
+
const feats = [...(item.features || [])];
|
| 425 |
+
feats[fi] = { ...feats[fi], icon: e.target.value };
|
| 426 |
+
updateItem(idx, { features: feats });
|
| 427 |
+
}} style={{ fontSize: 11, padding: "3px 4px", width: 70 }}>
|
| 428 |
+
{ICON_OPTIONS.map(ic => <option key={ic} value={ic}>{ic}</option>)}
|
| 429 |
+
</select>
|
| 430 |
+
<input value={ft.text} onChange={e => {
|
| 431 |
+
const feats = [...(item.features || [])];
|
| 432 |
+
feats[fi] = { ...feats[fi], text: e.target.value };
|
| 433 |
+
updateItem(idx, { features: feats });
|
| 434 |
+
}} placeholder="Feature text" style={{ flex: 1, fontSize: 12, padding: "3px 6px" }} />
|
| 435 |
+
<button className="btn sm" style={{ fontSize: 10, padding: "2px 6px" }}
|
| 436 |
+
onClick={() => {
|
| 437 |
+
const feats = (item.features || []).filter((_, i) => i !== fi);
|
| 438 |
+
updateItem(idx, { features: feats });
|
| 439 |
+
}}>×</button>
|
| 440 |
+
</div>
|
| 441 |
+
))}
|
| 442 |
+
<button className="btn sm" style={{ fontSize: 11, marginTop: 4 }}
|
| 443 |
+
onClick={() => updateItem(idx, { features: [...(item.features || []), { icon: "check", text: "" }] })}>
|
| 444 |
+
+ Add feature
|
| 445 |
+
</button>
|
| 446 |
+
</div>
|
| 447 |
+
)}
|
| 448 |
+
</div>
|
| 449 |
+
))}
|
| 450 |
+
|
| 451 |
+
{items.length > 0 && (
|
| 452 |
+
<button className="btn primary" style={{ width: "100%", marginTop: 8 }}
|
| 453 |
+
disabled={generating} onClick={startDesign}>
|
| 454 |
+
{generating ? "Generating..." : `Apply ${items.length} design(s)`}
|
| 455 |
+
</button>
|
| 456 |
+
)}
|
| 457 |
+
</div>
|
| 458 |
+
)}
|
| 459 |
+
|
| 460 |
+
{/* Log */}
|
| 461 |
+
{showLog && (
|
| 462 |
+
<div className="card">
|
| 463 |
+
<div className="panel-title">Progress</div>
|
| 464 |
+
{(busy || generating) && (
|
| 465 |
+
<div className="progress" style={{ marginBottom: 10 }}><i style={{ width: progress + "%" }} /></div>
|
| 466 |
+
)}
|
| 467 |
+
{status && <div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 8 }}>{status}</div>}
|
| 468 |
+
<div className="logbox" style={{ maxHeight: 200, fontSize: 11, lineHeight: 1.6 }}>
|
| 469 |
+
{logs.map((l, i) => (
|
| 470 |
+
<div key={i} className={l.includes("✅") ? "ok" : l.includes("❌") ? "err" : ""}>{l}</div>
|
| 471 |
+
))}
|
| 472 |
+
</div>
|
| 473 |
+
</div>
|
| 474 |
+
)}
|
| 475 |
+
|
| 476 |
+
{/* Results */}
|
| 477 |
+
{results.length > 0 && (
|
| 478 |
+
<div className="card">
|
| 479 |
+
<div className="panel-title">Results</div>
|
| 480 |
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 12 }}>
|
| 481 |
+
{results.filter(r => r.images?.length > 0).map((r, i) => (
|
| 482 |
+
<div key={i} style={{ border: "1px solid var(--line)", borderRadius: "var(--radius-sm)", overflow: "hidden" }}>
|
| 483 |
+
<img src={`/api/listing-free/file/${sessionId}/${r.images[0].split(/[/\\]/).pop()}`}
|
| 484 |
+
alt={r.label} style={{ width: "100%", aspectRatio: "1/1", objectFit: "cover", display: "block" }}
|
| 485 |
+
loading="lazy" />
|
| 486 |
+
<div style={{ padding: "6px 8px", fontSize: 12, fontWeight: 600 }}>{r.label}</div>
|
| 487 |
+
</div>
|
| 488 |
+
))}
|
| 489 |
+
</div>
|
| 490 |
+
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
|
| 491 |
+
<button className="btn primary" onClick={downloadAll}><IcDownload size={16} /> Download all</button>
|
| 492 |
+
<button className="btn" onClick={resetAll}><IcRefresh size={16} /> Reset</button>
|
| 493 |
+
</div>
|
| 494 |
+
</div>
|
| 495 |
+
)}
|
| 496 |
+
|
| 497 |
+
{!sessionId && !productPreview && (
|
| 498 |
+
<div className="card" style={{ textAlign: "center", padding: "40px 24px", color: "var(--muted)" }}>
|
| 499 |
+
<div className="chip" style={{ margin: "0 auto 12px" }}><IcStar size={24} /></div>
|
| 500 |
+
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6 }}>Listing ảnh Free</div>
|
| 501 |
+
<div style={{ fontSize: 12, lineHeight: 1.6 }}>
|
| 502 |
+
Upload a product image, add listing items with your own text, and generate professional listing images using Pillow design layers.
|
| 503 |
+
</div>
|
| 504 |
+
</div>
|
| 505 |
+
)}
|
| 506 |
+
</div>
|
| 507 |
+
|
| 508 |
+
<div className="rail">
|
| 509 |
+
<div className="rail-title">How it works</div>
|
| 510 |
+
<div className="rail-step">
|
| 511 |
+
<div className="num">1</div>
|
| 512 |
+
<div className="body">
|
| 513 |
+
<div className="t">Upload product image</div>
|
| 514 |
+
<div className="d">Choose a product photo and optional background.</div>
|
| 515 |
+
</div>
|
| 516 |
+
</div>
|
| 517 |
+
<div className="rail-step">
|
| 518 |
+
<div className="num">2</div>
|
| 519 |
+
<div className="body">
|
| 520 |
+
<div className="t">Add listing items</div>
|
| 521 |
+
<div className="d">Choose presets and customize text, fonts, colors per template.</div>
|
| 522 |
+
</div>
|
| 523 |
+
</div>
|
| 524 |
+
<div className="rail-step">
|
| 525 |
+
<div className="num">3</div>
|
| 526 |
+
<div className="body">
|
| 527 |
+
<div className="t">Generate designs</div>
|
| 528 |
+
<div className="d">Pillow renders text overlays with shadows, icons, badges.</div>
|
| 529 |
+
</div>
|
| 530 |
+
</div>
|
| 531 |
+
<div className="rail-step">
|
| 532 |
+
<div className="num">4</div>
|
| 533 |
+
<div className="body">
|
| 534 |
+
<div className="t">Download</div>
|
| 535 |
+
<div className="d">Get all images as a zip file — free, no API key needed.</div>
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
<div className="tip">
|
| 539 |
+
<b>Tip:</b> Add a background image for best results. Without one, the design overlays on a white or product image background.
|
| 540 |
+
</div>
|
| 541 |
+
</div>
|
| 542 |
+
</div>
|
| 543 |
+
</div>
|
| 544 |
+
);
|
| 545 |
+
}
|