tntgroup commited on
Commit
643d3ab
·
1 Parent(s): 78713c7

feat: add Listing anh Free tool (Pillow design + Wangp background, web UI)

Browse files
.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
+ }