sushilideaclan01 commited on
Commit
4c69ac5
·
1 Parent(s): 6caa461

added a new flow

Browse files
.gitignore CHANGED
@@ -29,3 +29,5 @@ backend/data/creativity_examples/*.png
29
  backend/data/creativity_examples/*.jpg
30
  backend/data/creativity_examples/*.jpeg
31
  backend/data/creativity_examples/*.webp
 
 
 
29
  backend/data/creativity_examples/*.jpg
30
  backend/data/creativity_examples/*.jpeg
31
  backend/data/creativity_examples/*.webp
32
+
33
+ output_creatives/
backend/app/analysis_flows.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
- Alternative creative analysis flows: Cross-vertical inspiration and Archetype rotation.
3
- These flows use reference images (creativity examples) and return ad creatives JSON directly,
4
  ready for image generation with product reference images.
5
  """
6
 
@@ -307,233 +307,3 @@ def _is_llm_refusal(text: str) -> bool:
307
  return any(p in lower for p in refusal_phrases) and len(text) < 500
308
 
309
 
310
- def analyze_product_archetype(
311
- product_data: dict,
312
- target_audience: list[str] | None = None,
313
- image_urls: list[str] | None = None,
314
- ) -> dict:
315
- """
316
- Performance-driven direct response creative analysis with archetype rotation.
317
- Returns 50 ad creatives as JSON directly, ready for image generation with product references.
318
- """
319
- category = product_data.get("category") or "Necklace"
320
- product_name = product_data.get("product_name", "")
321
- price = product_data.get("price", "")
322
- ref_urls = _get_reference_image_urls(image_urls)
323
- log.info("archetype: starting analysis, category=%s, image_count=%d", category, len(ref_urls))
324
- if ref_urls:
325
- log.info("creativity_examples: used for archetype flow (dir=%s)", _get_creativity_examples_dir() or "override")
326
- audience_block = ""
327
- if target_audience and len(target_audience) > 0:
328
- audience_list = ", ".join(target_audience)
329
- audience_block = f"""
330
- TARGET AUDIENCES (tailor strategies to these segments):
331
- {audience_list}
332
- """
333
-
334
- prompt = f"""
335
- You are a performance-driven direct response creative strategist.
336
-
337
- Your only priority is:
338
- - Scroll stopping power
339
- - Emotional tension
340
- - 2-second clarity
341
- - Identity signaling
342
- - Conversion psychology
343
- - Simplicity over cleverness
344
-
345
- Category: {category}
346
- {audience_block}
347
-
348
- ━━━━━━━━━━━━━━━━━━
349
- CREATIVE INTELLIGENCE MODE
350
- ━━━━━━━━━━━━━━━━━━
351
-
352
- Before generating creatives, internally identify:
353
-
354
- - Core emotional pain of the buyer
355
- - Hidden insecurity related to the product
356
- - Desired identity transformation
357
- - Social proof triggers
358
- - Purchase objections
359
- - Visual proof mechanisms
360
-
361
- Do NOT output this analysis.
362
- Use it to engineer stronger creatives.
363
-
364
- ━━━━━━━━━━━━━━━━━━
365
- CREATIVE DIRECTION
366
- ━━━━━━━━━━━━━━━━━━
367
-
368
- While the product is jewelry, do NOT think like a jewelry advertiser.
369
-
370
- Break category expectations.
371
- Borrow emotional structures from:
372
- - Movements
373
- - Rituals
374
- - Status symbols
375
- - Cultural moments
376
- - Social dynamics
377
- - Milestones
378
- - Symbolism
379
- - Personal declarations
380
-
381
- Every creative must anchor the product to:
382
- - A moment
383
- - A decision
384
- - A turning point
385
- - A social interaction
386
- - Or an identity shift
387
-
388
- Avoid generic "model wearing necklace" concepts unless paired with strong narrative tension.
389
-
390
- ━━━━━━━━━━━━━━━━━━
391
- CREATIVITY EXAMPLES = TEMPLATES (MANDATORY WHEN PROVIDED)
392
- ━━━━━━━━━━━━━━━━━━
393
-
394
- If creativity example images are attached, treat them as template references for creative construction.
395
- Use those templates to shape:
396
- - composition and framing
397
- - visual hierarchy
398
- - text placement style
399
- - lighting and color treatment
400
- - emotional tone and ad format structure
401
-
402
- For each generated creative, follow a template-like structure inspired by one or more reference examples,
403
- but adapt the scene and copy to this product and audience. Do not copy exact text from examples.
404
- Create fresh ad copy while preserving the template's creative pattern.
405
-
406
- Keep outputs diverse across all 30 creatives by rotating template influence and combining different template traits.
407
- Do not produce near-duplicates.
408
-
409
- ━━━━━━━━━━━━━━━━━━
410
- PRODUCT DATA
411
- ━━━━━━━━━━━━━━━━━━
412
- {json.dumps(product_data, indent=2)}
413
-
414
- ━━━━━━━━━━━━━━━━━━
415
- OBJECTIVE
416
- ━━━━━━━━━━━━━━━━━━
417
-
418
- Generate 25 concepts internally.
419
- Return ONLY the strongest 20.
420
-
421
- Affiliate marketing is performance-based.
422
- The goal is to maximize clicks, leads, or sales while remaining compliant.
423
-
424
- Low-production, realistic visuals preferred unless premium aesthetic clearly strengthens identity signaling.
425
-
426
- Assume the ad is viewed on a 6-inch mobile screen.
427
- If the hook is not clear within 2 seconds, it fails.
428
-
429
- ━━━━━━━━━━━━━━━━━━
430
- ARCHETYPE ROTATION (MANDATORY)
431
- ━━━━━━━━━━━━━━━━━━
432
-
433
- Use these archetypes:
434
-
435
- - Unexpected analogy visual (50% creatives)
436
-
437
- Each creative must be different from the others.
438
-
439
- Each creative: ONE dominant motivator, mobile-first, strong contrast. Avoid clutter, fake authority, sci-fi. Feel photographable. Lean into tension, declaration, transformation—reject weak jewelry-ad tropes.
440
-
441
- ━━━━━━━━━━━━━━━━━━
442
- CREATIVE RULES
443
- ━━━━━━━━━━━━━━━━━━
444
-
445
- Do NOT use "elegance" or "elegant" in any creative (titles, headlines, body, etc.). Use other descriptive terms.
446
-
447
- Each creative must:
448
- - Focus on ONE dominant motivator only
449
- - Be mobile-first
450
- - Use strong contrast
451
- - Avoid clutter
452
- - Avoid fake authority
453
- - Avoid fabricated claims
454
- - Avoid impersonation
455
- - Avoid unrealistic guarantees
456
- - Avoid abstract sci-fi or unrealistic imagery
457
- - Feel photographable in real life
458
-
459
- Increase emotional intensity beyond typical jewelry ads.
460
- Avoid soft, safe romance tones.
461
- Lean into tension, declaration, or transformation.
462
-
463
- Reject weak, safe, or predictable ideas.
464
- If a concept feels like a typical jewelry ad, discard it and replace it with a stronger one.
465
-
466
- Provide concise, execution-ready output.
467
- No explanations.
468
- No reasoning.
469
- No fluff.
470
-
471
- ━━━━━━━━━━━━━━━━━━
472
- PRODUCT DATA (use for scene_prompt—describe size, shape, material accurately for image generation)
473
- ━━━━━━━━━━━━━━━━━━
474
- {json.dumps(product_data, indent=2)}
475
-
476
- ━━━━━━━━━━━━━━━━━━
477
- JEWELRY PLACEMENT (MANDATORY for PRODUCT creatives)
478
- ━━━━━━━━━━━━━━━━━━
479
-
480
- In every scene_prompt you MUST specify WHERE the jewelry appears in the frame, in a way that is logical for the product type and the concept:
481
-
482
- - Necklace / pendant / chain: worn at collarbone/neck, or laid flat on surface for flat lay; never floating or in a random spot.
483
- - Earrings: on the model's ears, or placed on a surface for flat lay; never floating.
484
- - Bracelet / bangle: on wrist, or on surface for flat lay.
485
- - Ring: on finger, or centered on surface for flat lay.
486
- - Any product: lifestyle shot = worn on body in the correct position; flat lay = product on surface, centered or composed clearly; close-up = product fills frame in a natural way (e.g. neck for necklace, ear for earrings).
487
-
488
- Placement must match the shot type and concept. The image should look like a real photo: jewelry in a believable, on-concept position.
489
- Do NOT leave placement vague (e.g. "jewelry in the scene"). Always state the exact placement (e.g. "necklace at collarbone", "earrings on model's ears", "bracelet on wrist", "ring on finger", "pendant centered on marble surface").
490
-
491
- ━━━━━━━━━━━━━━━━━━
492
- OUTPUT SCHEMA (structured JSON)
493
- ━━━━━━━━━━━━━━━━━━
494
-
495
- Return 50 creatives. Each creative has:
496
- - creative_number: 1–50
497
- - archetype: one of the archetypes (Testimonial snapshot, Screenshot-style message, Comparison, Emotional turning-point, Identity declaration, Ritual/symbolism, Social proof, Scarcity, Movement/belonging, Unexpected analogy)
498
- - title: short concept name
499
- - visual_strategy: scene_prompt (describe background, lighting, framing AND explicit logical placement of the jewelry as above; end with: Ensure the jewelry is the dominant focal point in sharp focus. Preserve the product exactly as shown. Square 1:1, 1080x1080px.), shooting_angle, color_world, creative_type (PRODUCT or NO_PRODUCT), best_platform
500
- - text_on_image: headline_serif (UPPERCASE), headline_script, body (1–2 lines), cta, price_original/price_final where relevant (use ₹ format, product "{product_name}", price "{price}")
501
-
502
- Include price in exactly 17 creatives. Most creatives should be PRODUCT; up to 8 NO_PRODUCT.
503
- """
504
-
505
- content: list[dict] = [{"type": "text", "text": prompt}]
506
- for url in ref_urls:
507
- content.append({"type": "image_url", "image_url": {"url": url}})
508
-
509
- messages = [{"role": "user", "content": content}]
510
- raw = call_llm_vision(
511
- messages=messages,
512
- model="gpt-4o",
513
- temperature=0.85,
514
- response_format=CREATIVE_OUTPUT_JSON_SCHEMA,
515
- )
516
- result = raw or ""
517
- if _is_llm_refusal(result) and ref_urls:
518
- log.warning("archetype: LLM refused with images, retrying without reference images")
519
- content = [{"type": "text", "text": prompt}]
520
- raw = call_llm_vision(
521
- messages=[{"role": "user", "content": content}],
522
- model="gpt-4o",
523
- temperature=0.85,
524
- response_format=CREATIVE_OUTPUT_JSON_SCHEMA,
525
- )
526
- result = raw or ""
527
- if _is_llm_refusal(result):
528
- log.warning("archetype: LLM returned refusal")
529
- raise ValueError(
530
- "Creative analysis was blocked. Try with different images in CREATIVITY_EXAMPLES_DIR, "
531
- "or ensure the folder contains only ad/creative inspiration images."
532
- )
533
- try:
534
- out = json.loads(result)
535
- except json.JSONDecodeError:
536
- out = extract_json(result)
537
- creatives = out.get("creatives", [])
538
- log.info("archetype: analysis complete, creatives_count=%d", len(creatives))
539
- return {"creatives": creatives}
 
1
  """
2
+ Alternative creative analysis flow: Cross-vertical inspiration.
3
+ Uses reference images (creativity examples) and returns ad creatives JSON directly,
4
  ready for image generation with product reference images.
5
  """
6
 
 
307
  return any(p in lower for p in refusal_phrases) and len(text) < 500
308
 
309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/main.py CHANGED
@@ -15,11 +15,11 @@ from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from pathlib import Path
17
  from fastapi.responses import FileResponse, RedirectResponse, Response, StreamingResponse
18
- from urllib.parse import urlparse
19
  from pydantic import BaseModel
20
 
21
  from app.analysis import analyze_product
22
- from app.analysis_flows import analyze_product_archetype, analyze_product_cross_vertical
23
  from app.auth import authenticate_user, create_access_token, get_current_user
24
  from app.creatives import generate_ad_creatives
25
  from app.mongo import ensure_mongo_indexes, mongo_is_configured, ping_mongo
@@ -45,15 +45,32 @@ from app.canva_export import (
45
  )
46
  from app.replicate_image import MODEL_REGISTRY, REFERENCE_IMAGE_MODELS, generate_image
47
  from app.scraper import scrape_product
 
48
  from app.variation import generate_variations, generate_variations_stream
49
 
50
  load_dotenv()
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  # Env vars the app uses. (key, required, is_secret). Required = needed for core features; missing optional is just logged.
53
  _ENV_SPEC = [
54
  ("DATA_DIR", False, False), # Hugging Face writable dir; optional locally
55
  ("CREATIVITY_EXAMPLES_DIR", False, False), # Cross-vertical/archetype reference images
56
  ("OPENAI_API_KEY", True, True), # Analysis + creatives
 
57
  ("REPLICATE_API_TOKEN", False, True), # Generate ad images; optional
58
  ("KIE_API_KEY", False, True), # Kie.ai for nano-banana-pro; optional (see kie.ai/nano-banana-pro)
59
  ("BASE_URL", False, False), # Public URL for reference images (Replicate fetch)
@@ -87,6 +104,15 @@ def _check_env_on_startup():
87
  log.warning("Missing required env: %s — some endpoints will return 500.", ", ".join(missing_required))
88
 
89
 
 
 
 
 
 
 
 
 
 
90
  app = FastAPI(
91
  title="Amalfa Ad Creative Pipeline",
92
  description="Scrape product → deep analysis → generate ad creatives for Amalfa jewelry.",
@@ -122,13 +148,21 @@ class LoginRequest(BaseModel):
122
 
123
  CREATIVE_FLOW_PURELY_AESTHETIC = "purely_aesthetic"
124
  CREATIVE_FLOW_CROSS_VERTICAL = "cross_vertical"
125
- CREATIVE_FLOW_ARCHETYPE = "archetype_rotation"
126
 
127
 
128
  class RunPipelineRequest(BaseModel):
129
  url: str # Amalfa product page URL
130
  target_audience: list[str] | None = None # Optional audience segments for analysis/creatives (multi-select)
131
- creative_flow: str = CREATIVE_FLOW_PURELY_AESTHETIC # purely_aesthetic | cross_vertical | archetype_rotation
 
 
 
 
 
 
 
 
132
 
133
 
134
  def _ratio_to_width_height(ratio: str) -> tuple[int, int]:
@@ -161,6 +195,16 @@ class GenerateVariationsRequest(BaseModel):
161
  user_prompt: str | None = None
162
 
163
 
 
 
 
 
 
 
 
 
 
 
164
  # ----- Correction -----
165
  class ImageCorrectRequest(BaseModel):
166
  image_id: str # gallery entry id or "temp-id" when image not in gallery
@@ -178,6 +222,7 @@ class ImageCorrectResponse(BaseModel):
178
  STATIC_DIR = Path(__file__).resolve().parent / "static"
179
  # Amalfa logo served at GET /api/logo so Replicate can fetch it as second reference image
180
  LOGO_PATH = Path(__file__).resolve().parent / "amalfa_logo.png"
 
181
 
182
 
183
  def _run_gallery_cleanup():
@@ -196,9 +241,24 @@ def _run_gallery_cleanup():
196
  logging.getLogger("uvicorn.error").warning("Gallery cleanup failed: %s", e)
197
 
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  @app.on_event("startup")
200
  def _startup():
201
  """Check env vars and initialize MongoDB."""
 
202
  _check_env_on_startup()
203
  log = logging.getLogger("uvicorn.error")
204
  if mongo_is_configured():
@@ -245,7 +305,7 @@ def _run_creatives_step(
245
  """Run the creatives step based on flow type."""
246
  if creative_flow == CREATIVE_FLOW_CROSS_VERTICAL:
247
  return analysis_or_strategies
248
- if creative_flow == CREATIVE_FLOW_ARCHETYPE:
249
  return analysis_or_strategies
250
  # purely_aesthetic (default): analysis -> generate_ad_creatives
251
  return generate_ad_creatives(
@@ -259,7 +319,11 @@ def run_pipeline(body: RunPipelineRequest, _user: dict = Depends(get_current_use
259
  if not os.environ.get("OPENAI_API_KEY"):
260
  raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not set")
261
  flow = body.creative_flow or CREATIVE_FLOW_PURELY_AESTHETIC
262
- if flow not in (CREATIVE_FLOW_PURELY_AESTHETIC, CREATIVE_FLOW_CROSS_VERTICAL, CREATIVE_FLOW_ARCHETYPE):
 
 
 
 
263
  flow = CREATIVE_FLOW_PURELY_AESTHETIC
264
  log = logging.getLogger("uvicorn.error")
265
  log.info("run_pipeline: url=%s flow=%s", body.url[:60] + "..." if len(body.url) > 60 else body.url, flow)
@@ -272,10 +336,6 @@ def run_pipeline(body: RunPipelineRequest, _user: dict = Depends(get_current_use
272
  analysis_or_strategies = analyze_product_cross_vertical(
273
  product_data, target_audience=body.target_audience
274
  )
275
- elif flow == CREATIVE_FLOW_ARCHETYPE:
276
- analysis_or_strategies = analyze_product_archetype(
277
- product_data, target_audience=body.target_audience
278
- )
279
  else:
280
  analysis_or_strategies = analyze_product(
281
  product_data, target_audience=body.target_audience
@@ -283,7 +343,7 @@ def run_pipeline(body: RunPipelineRequest, _user: dict = Depends(get_current_use
283
  except Exception as e:
284
  raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
285
  creatives = None
286
- if flow == CREATIVE_FLOW_CROSS_VERTICAL or flow == CREATIVE_FLOW_ARCHETYPE:
287
  creatives = analysis_or_strategies
288
  log.info("run_pipeline: creatives from analysis (direct), count=%d", len(creatives.get("creatives", [])))
289
  else:
@@ -298,7 +358,7 @@ def run_pipeline(body: RunPipelineRequest, _user: dict = Depends(get_current_use
298
  if flow == CREATIVE_FLOW_PURELY_AESTHETIC:
299
  analysis_out = analysis_or_strategies
300
  else:
301
- flow_label = "Cross-vertical inspiration" if flow == CREATIVE_FLOW_CROSS_VERTICAL else "Archetype rotation"
302
  creative_list = creatives.get("creatives", [])
303
  def _concept_name(c):
304
  return c.get("title") or c.get("concept_name") or ""
@@ -443,7 +503,11 @@ def _stream_pipeline(
443
  yield _sse_message({"event": "error", "message": "OPENAI_API_KEY is not set"})
444
  return
445
  flow = creative_flow or CREATIVE_FLOW_PURELY_AESTHETIC
446
- if flow not in (CREATIVE_FLOW_PURELY_AESTHETIC, CREATIVE_FLOW_CROSS_VERTICAL, CREATIVE_FLOW_ARCHETYPE):
 
 
 
 
447
  flow = CREATIVE_FLOW_PURELY_AESTHETIC
448
  log = logging.getLogger("uvicorn.error")
449
  log.info("stream_pipeline: url=%s flow=%s", url[:60] + "..." if len(url) > 60 else url, flow)
@@ -456,9 +520,9 @@ def _stream_pipeline(
456
  yield _sse_message({"event": "step", "step": "analysis", "message": "Running cross-vertical inspiration…"})
457
  analysis_or_strategies = analyze_product_cross_vertical(product_data, target_audience=target_audience)
458
  creatives = analysis_or_strategies
459
- elif flow == CREATIVE_FLOW_ARCHETYPE:
460
- yield _sse_message({"event": "step", "step": "analysis", "message": "Running archetype rotation…"})
461
- analysis_or_strategies = analyze_product_archetype(product_data, target_audience=target_audience)
462
  creatives = analysis_or_strategies
463
  else:
464
  yield _sse_message({"event": "step", "step": "analysis", "message": "Running marketing analysis…"})
@@ -469,7 +533,7 @@ def _stream_pipeline(
469
  if flow == CREATIVE_FLOW_PURELY_AESTHETIC:
470
  analysis_out = analysis_or_strategies
471
  else:
472
- flow_label = "Cross-vertical inspiration" if flow == CREATIVE_FLOW_CROSS_VERTICAL else "Archetype rotation"
473
  creative_list = creatives.get("creatives", [])
474
  def _concept_name(c):
475
  return c.get("title") or c.get("concept_name") or ""
@@ -1152,6 +1216,158 @@ async def upload_reference(
1152
  return {"url": f"{base}/api/serve-reference/{name}", "filename": name}
1153
 
1154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1155
  @app.get("/api/serve-reference/{filename}")
1156
  def serve_reference(filename: str):
1157
  """Serve an uploaded reference image. Filename must be uuid.ext (safe)."""
@@ -1177,43 +1393,47 @@ async def proxy_image(url: str):
1177
  """
1178
  from urllib.parse import urlparse
1179
 
1180
- parsed = urlparse(url)
1181
- if parsed.scheme not in ("http", "https") or not parsed.netloc:
1182
- raise HTTPException(status_code=400, detail="Invalid URL")
1183
- if not parsed.netloc.endswith(R2_PROXY_ALLOWED_HOST_SUFFIX):
1184
- raise HTTPException(status_code=403, detail="URL not allowed")
1185
-
1186
  try:
1187
- async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
1188
- r = await client.get(url)
1189
- except httpx.RequestError:
1190
- # Network / DNS / timeout issues talking to R2
1191
- raise HTTPException(status_code=502, detail="Upstream request failed")
1192
-
1193
- # If R2 returns an error (e.g. expired or invalid presigned URL),
1194
- # surface that status code to the client instead of a generic 502.
1195
- if r.status_code >= 400:
1196
- # Common case: expired presigned URL → 403 from R2
1197
- detail = "Upstream returned error status"
1198
  try:
1199
- # Best-effort to pull a short message from JSON/text bodies
1200
- data = r.json()
1201
- msg = data.get("message") or data.get("error") or None
1202
- if isinstance(msg, str) and msg:
1203
- detail = msg
1204
- except Exception:
1205
- text = (r.text or "").strip()
1206
- if text:
1207
- # Truncate very long HTML/XML error pages
1208
- detail = text[:512]
1209
- raise HTTPException(status_code=r.status_code, detail=detail)
1210
-
1211
- content_type = r.headers.get("content-type", "image/png")
1212
- return Response(
1213
- content=r.content,
1214
- media_type=content_type,
1215
- headers={"Cache-Control": "private, max-age=3600"},
1216
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1217
 
1218
 
1219
  # SPA fallback: serve index.html for non-API routes when static build is present
 
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from pathlib import Path
17
  from fastapi.responses import FileResponse, RedirectResponse, Response, StreamingResponse
18
+ from urllib.parse import quote, urlparse
19
  from pydantic import BaseModel
20
 
21
  from app.analysis import analyze_product
22
+ from app.analysis_flows import analyze_product_cross_vertical
23
  from app.auth import authenticate_user, create_access_token, get_current_user
24
  from app.creatives import generate_ad_creatives
25
  from app.mongo import ensure_mongo_indexes, mongo_is_configured, ping_mongo
 
45
  )
46
  from app.replicate_image import MODEL_REGISTRY, REFERENCE_IMAGE_MODELS, generate_image
47
  from app.scraper import scrape_product
48
+ from app.template_flow import run_template_based_creatives
49
  from app.variation import generate_variations, generate_variations_stream
50
 
51
  load_dotenv()
52
 
53
+
54
+ class _SuppressNoisyAccessFilter(logging.Filter):
55
+ """Hide very noisy access-log endpoints from terminal output."""
56
+
57
+ _SUPPRESSED_PATHS = (
58
+ "/api/proxy-image",
59
+ )
60
+
61
+ def filter(self, record: logging.LogRecord) -> bool:
62
+ try:
63
+ msg = record.getMessage()
64
+ return not any(path in msg for path in self._SUPPRESSED_PATHS)
65
+ except Exception:
66
+ return True
67
+
68
  # Env vars the app uses. (key, required, is_secret). Required = needed for core features; missing optional is just logged.
69
  _ENV_SPEC = [
70
  ("DATA_DIR", False, False), # Hugging Face writable dir; optional locally
71
  ("CREATIVITY_EXAMPLES_DIR", False, False), # Cross-vertical/archetype reference images
72
  ("OPENAI_API_KEY", True, True), # Analysis + creatives
73
+ ("ANTHROPIC_API_KEY", False, True), # Template-based analysis flow
74
  ("REPLICATE_API_TOKEN", False, True), # Generate ad images; optional
75
  ("KIE_API_KEY", False, True), # Kie.ai for nano-banana-pro; optional (see kie.ai/nano-banana-pro)
76
  ("BASE_URL", False, False), # Public URL for reference images (Replicate fetch)
 
104
  log.warning("Missing required env: %s — some endpoints will return 500.", ", ".join(missing_required))
105
 
106
 
107
+ def _configure_access_log_filters():
108
+ """Attach a filter to uvicorn access logger to reduce terminal spam."""
109
+ access_logger = logging.getLogger("uvicorn.access")
110
+ for f in access_logger.filters:
111
+ if isinstance(f, _SuppressNoisyAccessFilter):
112
+ return
113
+ access_logger.addFilter(_SuppressNoisyAccessFilter())
114
+
115
+
116
  app = FastAPI(
117
  title="Amalfa Ad Creative Pipeline",
118
  description="Scrape product → deep analysis → generate ad creatives for Amalfa jewelry.",
 
148
 
149
  CREATIVE_FLOW_PURELY_AESTHETIC = "purely_aesthetic"
150
  CREATIVE_FLOW_CROSS_VERTICAL = "cross_vertical"
151
+ CREATIVE_FLOW_TEMPLATE_BASED = "template_based_creatives"
152
 
153
 
154
  class RunPipelineRequest(BaseModel):
155
  url: str # Amalfa product page URL
156
  target_audience: list[str] | None = None # Optional audience segments for analysis/creatives (multi-select)
157
+ creative_flow: str = CREATIVE_FLOW_PURELY_AESTHETIC # purely_aesthetic | cross_vertical | template_based_creatives
158
+
159
+
160
+ def _flow_label(flow: str) -> str:
161
+ if flow == CREATIVE_FLOW_CROSS_VERTICAL:
162
+ return "Cross-vertical inspiration"
163
+ if flow == CREATIVE_FLOW_TEMPLATE_BASED:
164
+ return "Template based creatives"
165
+ return "Purely aesthetic"
166
 
167
 
168
  def _ratio_to_width_height(ratio: str) -> tuple[int, int]:
 
195
  user_prompt: str | None = None
196
 
197
 
198
+ class TemplateCreativesRunRequest(BaseModel):
199
+ product_url: str
200
+ template_image_urls: list[str]
201
+ product_image_urls: list[str] | None = None
202
+ logo_image_url: str | None = None
203
+ model_key: str = "nano-banana-2"
204
+ num_outputs: int = 1
205
+ aspect_ratio: str = "1:1"
206
+
207
+
208
  # ----- Correction -----
209
  class ImageCorrectRequest(BaseModel):
210
  image_id: str # gallery entry id or "temp-id" when image not in gallery
 
222
  STATIC_DIR = Path(__file__).resolve().parent / "static"
223
  # Amalfa logo served at GET /api/logo so Replicate can fetch it as second reference image
224
  LOGO_PATH = Path(__file__).resolve().parent / "amalfa_logo.png"
225
+ TEMPLATE_EXAMPLES_SAFE = re.compile(r"^[A-Za-z0-9 _().\-]+$", re.I)
226
 
227
 
228
  def _run_gallery_cleanup():
 
241
  logging.getLogger("uvicorn.error").warning("Gallery cleanup failed: %s", e)
242
 
243
 
244
+ def _is_supported_image_file(path: Path) -> bool:
245
+ return path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}
246
+
247
+
248
+ def _get_creativity_examples_dir() -> Path:
249
+ env_dir = (os.environ.get("CREATIVITY_EXAMPLES_DIR") or "").strip()
250
+ if env_dir:
251
+ p = Path(env_dir)
252
+ if p.is_dir():
253
+ return p
254
+ fallback = Path(__file__).resolve().parent.parent / "data" / "creativity_examples"
255
+ return fallback
256
+
257
+
258
  @app.on_event("startup")
259
  def _startup():
260
  """Check env vars and initialize MongoDB."""
261
+ _configure_access_log_filters()
262
  _check_env_on_startup()
263
  log = logging.getLogger("uvicorn.error")
264
  if mongo_is_configured():
 
305
  """Run the creatives step based on flow type."""
306
  if creative_flow == CREATIVE_FLOW_CROSS_VERTICAL:
307
  return analysis_or_strategies
308
+ if creative_flow == CREATIVE_FLOW_TEMPLATE_BASED:
309
  return analysis_or_strategies
310
  # purely_aesthetic (default): analysis -> generate_ad_creatives
311
  return generate_ad_creatives(
 
319
  if not os.environ.get("OPENAI_API_KEY"):
320
  raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not set")
321
  flow = body.creative_flow or CREATIVE_FLOW_PURELY_AESTHETIC
322
+ if flow not in (
323
+ CREATIVE_FLOW_PURELY_AESTHETIC,
324
+ CREATIVE_FLOW_CROSS_VERTICAL,
325
+ CREATIVE_FLOW_TEMPLATE_BASED,
326
+ ):
327
  flow = CREATIVE_FLOW_PURELY_AESTHETIC
328
  log = logging.getLogger("uvicorn.error")
329
  log.info("run_pipeline: url=%s flow=%s", body.url[:60] + "..." if len(body.url) > 60 else body.url, flow)
 
336
  analysis_or_strategies = analyze_product_cross_vertical(
337
  product_data, target_audience=body.target_audience
338
  )
 
 
 
 
339
  else:
340
  analysis_or_strategies = analyze_product(
341
  product_data, target_audience=body.target_audience
 
343
  except Exception as e:
344
  raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
345
  creatives = None
346
+ if flow in (CREATIVE_FLOW_CROSS_VERTICAL, CREATIVE_FLOW_TEMPLATE_BASED):
347
  creatives = analysis_or_strategies
348
  log.info("run_pipeline: creatives from analysis (direct), count=%d", len(creatives.get("creatives", [])))
349
  else:
 
358
  if flow == CREATIVE_FLOW_PURELY_AESTHETIC:
359
  analysis_out = analysis_or_strategies
360
  else:
361
+ flow_label = _flow_label(flow)
362
  creative_list = creatives.get("creatives", [])
363
  def _concept_name(c):
364
  return c.get("title") or c.get("concept_name") or ""
 
503
  yield _sse_message({"event": "error", "message": "OPENAI_API_KEY is not set"})
504
  return
505
  flow = creative_flow or CREATIVE_FLOW_PURELY_AESTHETIC
506
+ if flow not in (
507
+ CREATIVE_FLOW_PURELY_AESTHETIC,
508
+ CREATIVE_FLOW_CROSS_VERTICAL,
509
+ CREATIVE_FLOW_TEMPLATE_BASED,
510
+ ):
511
  flow = CREATIVE_FLOW_PURELY_AESTHETIC
512
  log = logging.getLogger("uvicorn.error")
513
  log.info("stream_pipeline: url=%s flow=%s", url[:60] + "..." if len(url) > 60 else url, flow)
 
520
  yield _sse_message({"event": "step", "step": "analysis", "message": "Running cross-vertical inspiration…"})
521
  analysis_or_strategies = analyze_product_cross_vertical(product_data, target_audience=target_audience)
522
  creatives = analysis_or_strategies
523
+ elif flow == CREATIVE_FLOW_TEMPLATE_BASED:
524
+ yield _sse_message({"event": "step", "step": "analysis", "message": "Running template based creatives…"})
525
+ analysis_or_strategies = analyze_product(product_data, target_audience=target_audience)
526
  creatives = analysis_or_strategies
527
  else:
528
  yield _sse_message({"event": "step", "step": "analysis", "message": "Running marketing analysis…"})
 
533
  if flow == CREATIVE_FLOW_PURELY_AESTHETIC:
534
  analysis_out = analysis_or_strategies
535
  else:
536
+ flow_label = _flow_label(flow)
537
  creative_list = creatives.get("creatives", [])
538
  def _concept_name(c):
539
  return c.get("title") or c.get("concept_name") or ""
 
1216
  return {"url": f"{base}/api/serve-reference/{name}", "filename": name}
1217
 
1218
 
1219
+ @app.get("/api/template-examples")
1220
+ def list_template_examples(request: Request, _user: dict = Depends(get_current_user)):
1221
+ """List available creativity example templates for template-based flow."""
1222
+ examples_dir = _get_creativity_examples_dir()
1223
+ if not examples_dir.is_dir():
1224
+ return {"templates": []}
1225
+ names = sorted(
1226
+ [p.name for p in examples_dir.iterdir() if p.is_file() and _is_supported_image_file(p)],
1227
+ key=lambda x: x.lower(),
1228
+ )
1229
+ return {
1230
+ "templates": [
1231
+ {
1232
+ "name": name,
1233
+ # Same-origin relative URL avoids broken previews when BASE_URL points elsewhere.
1234
+ "url": f"/api/template-example/{quote(name)}",
1235
+ }
1236
+ for name in names
1237
+ ]
1238
+ }
1239
+
1240
+
1241
+ @app.get("/api/template-example/{filename}")
1242
+ def serve_template_example(filename: str):
1243
+ """Serve a template example image from configured creativity examples directory."""
1244
+ if not TEMPLATE_EXAMPLES_SAFE.match(filename):
1245
+ raise HTTPException(status_code=400, detail="Invalid filename")
1246
+ examples_dir = _get_creativity_examples_dir()
1247
+ if not examples_dir.is_dir():
1248
+ raise HTTPException(status_code=404, detail="Template examples directory not found")
1249
+ path = (examples_dir / filename).resolve()
1250
+ try:
1251
+ path.relative_to(examples_dir.resolve())
1252
+ except ValueError:
1253
+ raise HTTPException(status_code=400, detail="Invalid filename")
1254
+ if not path.is_file() or not _is_supported_image_file(path):
1255
+ raise HTTPException(status_code=404, detail="Template not found")
1256
+ media = "image/png" if path.suffix.lower() == ".png" else "image/jpeg" if path.suffix.lower() in (".jpg", ".jpeg") else "image/webp"
1257
+ return FileResponse(path, media_type=media)
1258
+
1259
+
1260
+ @app.post("/api/template-creatives/run")
1261
+ async def run_template_creatives(
1262
+ body: TemplateCreativesRunRequest,
1263
+ request: Request,
1264
+ _user: dict = Depends(get_current_user),
1265
+ ):
1266
+ """
1267
+ Run full template-based flow and return final generated image URLs directly.
1268
+ """
1269
+ if not body.product_url.strip():
1270
+ raise HTTPException(status_code=400, detail="product_url is required")
1271
+ template_urls = [u.strip() for u in (body.template_image_urls or []) if isinstance(u, str) and u.strip()]
1272
+ if not template_urls:
1273
+ raise HTTPException(status_code=400, detail="template_image_urls is required")
1274
+ if body.num_outputs < 1 or body.num_outputs > 6:
1275
+ raise HTTPException(status_code=400, detail="num_outputs must be between 1 and 6")
1276
+ if body.model_key not in MODEL_REGISTRY:
1277
+ raise HTTPException(status_code=400, detail=f"Unknown model: {body.model_key}")
1278
+ width, height = _ratio_to_width_height(body.aspect_ratio)
1279
+ template_image_url = template_urls[0]
1280
+ additional_template_urls = template_urls[1:]
1281
+ logo_image_url = body.logo_image_url.strip() if body.logo_image_url else None
1282
+ # Allow frontend to send same-origin relative URLs for local previews.
1283
+ # For generation, prefer BASE_URL/public host so Replicate can fetch references.
1284
+ public_base = _reference_image_base_url(request).rstrip("/")
1285
+ if template_image_url.startswith("/"):
1286
+ template_image_url = f"{public_base}{template_image_url}"
1287
+ normalized_additional_template_urls: list[str] = []
1288
+ for u in additional_template_urls:
1289
+ if u.startswith("/"):
1290
+ normalized_additional_template_urls.append(f"{public_base}{u}")
1291
+ else:
1292
+ normalized_additional_template_urls.append(u)
1293
+ if logo_image_url and logo_image_url.startswith("/"):
1294
+ logo_image_url = f"{public_base}{logo_image_url}"
1295
+ # Replicate cannot fetch localhost/loopback URLs from cloud workers.
1296
+ # Fail early with an actionable message instead of retry noise.
1297
+ blocked_hosts = {"127.0.0.1", "localhost", "0.0.0.0"}
1298
+ check_urls = [template_image_url, *normalized_additional_template_urls]
1299
+ if logo_image_url:
1300
+ check_urls.append(logo_image_url)
1301
+ for u in check_urls:
1302
+ host = (urlparse(u).hostname or "").lower()
1303
+ if host in blocked_hosts:
1304
+ raise HTTPException(
1305
+ status_code=400,
1306
+ detail=(
1307
+ "Template/logo reference URL is localhost and cannot be fetched by Replicate. "
1308
+ "Set a public BASE_URL (for example ngrok/cloudflared URL) and retry."
1309
+ ),
1310
+ )
1311
+ try:
1312
+ result = await asyncio.to_thread(
1313
+ run_template_based_creatives,
1314
+ body.product_url.strip(),
1315
+ template_image_url,
1316
+ normalized_additional_template_urls,
1317
+ body.product_image_urls,
1318
+ logo_image_url,
1319
+ 1,
1320
+ width,
1321
+ height,
1322
+ body.model_key,
1323
+ )
1324
+ except Exception as e:
1325
+ logging.getLogger("uvicorn.error").exception(
1326
+ "template_creatives_run failed: product_url=%s template_count=%d aspect_ratio=%s",
1327
+ body.product_url.strip(),
1328
+ len(template_urls),
1329
+ body.aspect_ratio,
1330
+ )
1331
+ raise HTTPException(status_code=500, detail=f"Template flow failed: {str(e)}")
1332
+ # Persist generated template creatives to gallery (same storage path as /api/generate-ads).
1333
+ username = (_user or {}).get("username", "")
1334
+ product_name = ((result.get("meta") or {}).get("product_name") or "").strip()
1335
+ scene_prompt = ((result.get("analysis") or {}).get("image_generation_prompt") or "").strip()
1336
+ out_images = result.get("images") or []
1337
+ saved_images: list[str] = []
1338
+ for idx, src_url in enumerate(out_images, start=1):
1339
+ final_url = src_url
1340
+ r2_key = None
1341
+ try:
1342
+ r2_url, r2_key = await _save_creative_to_r2(
1343
+ src_url,
1344
+ creative_id=idx,
1345
+ concept_name=f"Template based creative {idx}",
1346
+ product_name=product_name,
1347
+ )
1348
+ if r2_url:
1349
+ final_url = r2_url
1350
+ except Exception:
1351
+ r2_key = None
1352
+ if r2_key and username:
1353
+ try:
1354
+ await asyncio.to_thread(
1355
+ gallery_append_entry,
1356
+ username,
1357
+ r2_key=r2_key,
1358
+ concept_name=f"Template based creative {idx}",
1359
+ creative_id=idx,
1360
+ product_name=product_name,
1361
+ scene_prompt=scene_prompt or None,
1362
+ image_model=body.model_key,
1363
+ )
1364
+ except Exception:
1365
+ pass
1366
+ saved_images.append(final_url)
1367
+ result["images"] = saved_images
1368
+ return result
1369
+
1370
+
1371
  @app.get("/api/serve-reference/{filename}")
1372
  def serve_reference(filename: str):
1373
  """Serve an uploaded reference image. Filename must be uuid.ext (safe)."""
 
1393
  """
1394
  from urllib.parse import urlparse
1395
 
 
 
 
 
 
 
1396
  try:
1397
+ parsed = urlparse(url)
1398
+ host = (parsed.hostname or "").lower()
1399
+ if parsed.scheme not in ("http", "https") or not host:
1400
+ raise HTTPException(status_code=400, detail="Invalid URL")
1401
+ if not host.endswith(R2_PROXY_ALLOWED_HOST_SUFFIX):
1402
+ raise HTTPException(status_code=403, detail="URL not allowed")
1403
+
 
 
 
 
1404
  try:
1405
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
1406
+ r = await client.get(url)
1407
+ except httpx.HTTPError:
1408
+ # Network / DNS / timeout issues talking to R2
1409
+ raise HTTPException(status_code=502, detail="Upstream request failed")
1410
+
1411
+ if r.status_code >= 400:
1412
+ detail = "Upstream returned error status"
1413
+ try:
1414
+ data = r.json()
1415
+ msg = data.get("message") or data.get("error") or None
1416
+ if isinstance(msg, str) and msg:
1417
+ detail = msg
1418
+ except Exception:
1419
+ body = (r.content or b"")[:512]
1420
+ if body:
1421
+ detail = body.decode("utf-8", errors="replace").strip() or detail
1422
+ # Never mirror upstream 5xx as our own 5xx.
1423
+ status = r.status_code if 400 <= r.status_code < 500 else 502
1424
+ raise HTTPException(status_code=status, detail=detail)
1425
+
1426
+ content_type = r.headers.get("content-type", "image/png")
1427
+ return Response(
1428
+ content=r.content,
1429
+ media_type=content_type,
1430
+ headers={"Cache-Control": "private, max-age=3600"},
1431
+ )
1432
+ except HTTPException:
1433
+ raise
1434
+ except Exception as e:
1435
+ logging.getLogger("uvicorn.error").warning("proxy_image unexpected error: %s", e)
1436
+ raise HTTPException(status_code=502, detail="Image proxy failed")
1437
 
1438
 
1439
  # SPA fallback: serve index.html for non-API routes when static build is present
backend/app/template_flow.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Template-based creative generation flow used by the app API.
3
+
4
+ Flow:
5
+ 1) Scrape product URL and pick product image
6
+ 2) Send one fixed prompt + ordered references directly to image model
7
+ 3) Return final generated image URLs
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ import time
13
+
14
+ from app.replicate_image import generate_image_sync
15
+ from app.scraper import scrape_product
16
+
17
+ DEFAULT_MODEL_KEY = "nano-banana-2"
18
+ REPLICATE_API_KEY = os.getenv("REPLICATE_API_KEY") or os.getenv("REPLICATE_API_TOKEN") or ""
19
+ GENERATION_MAX_ATTEMPTS = 3
20
+ GENERATION_RETRY_DELAY_SEC = 4
21
+ log = logging.getLogger("uvicorn.error")
22
+
23
+ VISION_USER_PROMPT = """Use the first (left) image as the design template and layout reference. Create a high-converting advertisement for the product shown in the second (middle) image. and third (last) image is the brand logo of this jewellery brand product.
24
+ Maintain the same structure, typography style, and visual hierarchy from the template, but adapt it creatively to fit the new product.
25
+ Focus on:
26
+
27
+ Clean product placement
28
+
29
+ Short, benefit-driven copy (not generic)
30
+
31
+ Modern, premium aesthetic
32
+
33
+ Feel free to enhance colors, lighting, and composition to make the product stand out and look more desirable."""
34
+
35
+
36
+ def _is_url(value: str) -> bool:
37
+ return value.startswith("http://") or value.startswith("https://")
38
+
39
+
40
+ def scrape_product_image_url(product_url: str) -> tuple[str, dict]:
41
+ data = scrape_product(product_url)
42
+ images = [u.strip() for u in (data.get("product_images") or "").split(",") if u.strip()]
43
+ first = next((u for u in images if _is_url(u)), "")
44
+ if not first:
45
+ raise ValueError("No valid product image URL found after scraping.")
46
+ return first, data
47
+
48
+
49
+ def generate_with_nano_banana(
50
+ base_prompt: str,
51
+ reference_image_urls: list[str],
52
+ width: int,
53
+ height: int,
54
+ num_outputs: int,
55
+ model_key: str = DEFAULT_MODEL_KEY,
56
+ ) -> list[str]:
57
+ os.environ["REPLICATE_API_TOKEN"] = REPLICATE_API_KEY
58
+ refs = [u for i, u in enumerate(reference_image_urls) if u and u not in reference_image_urls[:i]]
59
+ log.info(
60
+ "template_flow: generation start model=%s refs=%d size=%sx%s outputs=%d",
61
+ model_key,
62
+ len(refs),
63
+ width,
64
+ height,
65
+ num_outputs,
66
+ )
67
+ urls: list[str] = []
68
+ for output_idx in range(num_outputs):
69
+ final_url = None
70
+ final_err = "Image generation failed."
71
+ for attempt in range(1, GENERATION_MAX_ATTEMPTS + 1):
72
+ log.info(
73
+ "template_flow: generation attempt output=%d/%d attempt=%d/%d",
74
+ output_idx + 1,
75
+ num_outputs,
76
+ attempt,
77
+ GENERATION_MAX_ATTEMPTS,
78
+ )
79
+ url, err = generate_image_sync(
80
+ prompt=base_prompt,
81
+ model_key=model_key,
82
+ width=width,
83
+ height=height,
84
+ reference_image_urls=refs,
85
+ )
86
+ if url and not err:
87
+ final_url = url
88
+ log.info(
89
+ "template_flow: generation success output=%d/%d attempt=%d url=%s",
90
+ output_idx + 1,
91
+ num_outputs,
92
+ attempt,
93
+ final_url,
94
+ )
95
+ break
96
+ final_err = err or "Image generation returned no URL."
97
+ log.warning(
98
+ "template_flow: generation failed output=%d/%d attempt=%d err=%s",
99
+ output_idx + 1,
100
+ num_outputs,
101
+ attempt,
102
+ final_err,
103
+ )
104
+ if attempt < GENERATION_MAX_ATTEMPTS:
105
+ time.sleep(GENERATION_RETRY_DELAY_SEC)
106
+ if not final_url:
107
+ raise RuntimeError(f"Image generation failed after {GENERATION_MAX_ATTEMPTS} attempts: {final_err}")
108
+ urls.append(final_url)
109
+ return urls
110
+
111
+
112
+ def run_template_based_creatives(
113
+ product_url: str,
114
+ template_image_url: str,
115
+ additional_template_image_urls: list[str] | None,
116
+ product_image_urls: list[str] | None,
117
+ logo_image_url: str | None,
118
+ num_outputs: int,
119
+ width: int,
120
+ height: int,
121
+ model_key: str = DEFAULT_MODEL_KEY,
122
+ ) -> dict:
123
+ if not template_image_url:
124
+ raise ValueError("template_image_url is required")
125
+ log.info(
126
+ "template_flow: run start product_url=%s template_url=%s additional_templates=%d selected_product_refs=%d logo=%s",
127
+ product_url,
128
+ template_image_url,
129
+ len(additional_template_image_urls or []),
130
+ len(product_image_urls or []),
131
+ bool(logo_image_url),
132
+ )
133
+ product_image_url, product_data = scrape_product_image_url(product_url)
134
+ selected_product_refs = [u for u in (product_image_urls or []) if isinstance(u, str) and _is_url(u)]
135
+ if selected_product_refs:
136
+ product_image_url = selected_product_refs[0]
137
+ templates = [template_image_url] + [u for u in (additional_template_image_urls or []) if u]
138
+ # Preserve order while de-duplicating.
139
+ ordered_templates = [u for i, u in enumerate(templates) if u not in templates[:i]]
140
+ output_urls: list[str] = []
141
+ analyses: list[dict] = []
142
+ # Generate one image per template x product-reference combination.
143
+ product_variants = selected_product_refs if selected_product_refs else [product_image_url]
144
+ for idx, template_ref in enumerate(ordered_templates):
145
+ log.info(
146
+ "template_flow: processing template %d/%d template_ref=%s",
147
+ idx + 1,
148
+ len(ordered_templates),
149
+ template_ref,
150
+ )
151
+ try:
152
+ # Simple direct mode: use one fixed prompt for generation.
153
+ analyses.append({
154
+ "template_analysis": "direct_template_prompt_mode",
155
+ "image_generation_prompt": VISION_USER_PROMPT,
156
+ })
157
+ base_prompt = VISION_USER_PROMPT
158
+ for ref_idx, product_ref in enumerate(product_variants):
159
+ # Prompt expects ordered references: 1) template, 2) product, 3) logo.
160
+ refs = [template_ref, product_ref]
161
+ if logo_image_url:
162
+ refs.append(logo_image_url)
163
+ log.info(
164
+ "template_flow: variant generation template=%d/%d product_ref=%d/%d",
165
+ idx + 1,
166
+ len(ordered_templates),
167
+ ref_idx + 1,
168
+ len(product_variants),
169
+ )
170
+ per_variant_urls = generate_with_nano_banana(
171
+ base_prompt,
172
+ refs,
173
+ width,
174
+ height,
175
+ 1,
176
+ model_key=model_key,
177
+ )
178
+ output_urls.extend(per_variant_urls)
179
+ except Exception:
180
+ log.exception(
181
+ "template_flow: failed while processing template %d/%d template_ref=%s",
182
+ idx + 1,
183
+ len(ordered_templates),
184
+ template_ref,
185
+ )
186
+ raise
187
+ log.info(
188
+ "template_flow: run complete product=%s templates=%d product_variants=%d generated_images=%d",
189
+ product_data.get("product_name", ""),
190
+ len(ordered_templates),
191
+ len(product_variants),
192
+ len(output_urls),
193
+ )
194
+ return {
195
+ "images": output_urls,
196
+ "analysis": analyses[0] if analyses else {},
197
+ "analyses": analyses,
198
+ "meta": {
199
+ "product_url": product_url,
200
+ "selected_product_image_url": product_image_url,
201
+ "selected_product_image_urls": selected_product_refs,
202
+ "template_image_url": template_image_url,
203
+ "template_image_urls": ordered_templates,
204
+ "additional_template_image_urls": additional_template_image_urls or [],
205
+ "logo_image_url": logo_image_url,
206
+ "product_name": product_data.get("product_name", ""),
207
+ "model_key": model_key,
208
+ "num_outputs": len(output_urls),
209
+ "width": width,
210
+ "height": height,
211
+ },
212
+ "product_data": product_data,
213
+ }
backend/data/creativity_examples/036.png DELETED

Git LFS Details

  • SHA256: a468a2c9594205a91211ba47a3d1241fc70216dbdd6df2160fb94946e3d9d153
  • Pointer size: 131 Bytes
  • Size of remote file: 139 kB
backend/data/creativity_examples/CDDDAD88-3F75-4EE2-A61D-C70690B11D4F.jpg DELETED

Git LFS Details

  • SHA256: 829048b30f8534522ea3c8667c0736d66854f8735cd6860e0f14a7e59fd370ee
  • Pointer size: 131 Bytes
  • Size of remote file: 852 kB
backend/data/creativity_examples/image (10).png DELETED

Git LFS Details

  • SHA256: 05e7ccfabdf1836559efdc0a38ed6d820b8645bb1b96a0b9b41d133d129ecd54
  • Pointer size: 132 Bytes
  • Size of remote file: 4.65 MB
fourth_flow.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ad Creative Generator (Fourth Flow)
3
+ -----------------------------------
4
+ Flow:
5
+ 1) Scrape product URL to fetch product image
6
+ 2) Run one Gemini vision call for analysis + generation prompt
7
+ 3) Generate image via nano-banana-2 with product/template/logo references
8
+ 4) Save creative + analysis payload to output_creatives/
9
+ """
10
+
11
+ import base64
12
+ import json
13
+ import os
14
+ import shutil
15
+ import sys
16
+ import time
17
+ import uuid
18
+ from pathlib import Path
19
+
20
+ import requests
21
+ from dotenv import load_dotenv
22
+ from google import genai
23
+ from google.genai import types
24
+
25
+ load_dotenv()
26
+
27
+ # Local backend imports
28
+ BACKEND_DIR = Path(__file__).resolve().parent / "backend"
29
+ if str(BACKEND_DIR) not in sys.path:
30
+ sys.path.insert(0, str(BACKEND_DIR))
31
+
32
+ from app.replicate_image import generate_image_sync # noqa: E402 # type: ignore[reportMissingImports]
33
+ from app.scraper import scrape_product # noqa: E402 # type: ignore[reportMissingImports]
34
+
35
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "YOUR_GEMINI_API_KEY")
36
+ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.1-pro-preview")
37
+ MODEL_KEY = "nano-banana-2"
38
+ REPLICATE_API_KEY = os.getenv("REPLICATE_API_KEY") or os.getenv("REPLICATE_API_TOKEN") or "YOUR_REPLICATE_API_KEY"
39
+ GENERATION_MAX_ATTEMPTS = 3
40
+ GENERATION_RETRY_DELAY_SEC = 4
41
+
42
+ OUTPUT_DIR = Path("output_creatives")
43
+ OUTPUT_DIR.mkdir(exist_ok=True)
44
+ REFERENCE_UPLOAD_DIR = BACKEND_DIR / "reference_uploads"
45
+ REFERENCE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
46
+
47
+
48
+ VISION_USER_PROMPT = """You are an expert creative director and ad-tech specialist.
49
+ Analyse these images and return ONLY valid JSON with exactly these keys:
50
+ - template_analysis
51
+ - product_description
52
+ - brand_info
53
+ - image_generation_prompt
54
+ - negative_prompt
55
+
56
+ Use first image as template/layout reference, second image as product, third image (if present) as logo/brand cue.
57
+ CRITICAL: Treat the template like a copy-paste layout lock.
58
+ - Keep the same composition, block structure, spacing, framing, and text placement zones as template.
59
+ - Keep the same visual style direction (editorial/commercial look), not a new design.
60
+ - Only change the content inside the template: product subject, brand/logo presence, and copy text.
61
+ - Do NOT invent a different layout or scene format.
62
+
63
+ Make image_generation_prompt optimized for premium photorealistic jewelry ad output"""
64
+
65
+
66
+ def load_image_as_b64(path: str) -> str:
67
+ with open(path, "rb") as f:
68
+ return base64.b64encode(f.read()).decode("utf-8")
69
+
70
+
71
+ def _is_supported_image(path: Path) -> bool:
72
+ return path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}
73
+
74
+
75
+ def _reference_image_base_url() -> str:
76
+ return (os.getenv("BASE_URL") or "http://localhost:8002").rstrip("/")
77
+
78
+
79
+ def _publish_local_reference(path: str) -> str:
80
+ src = Path(path)
81
+ if not src.exists():
82
+ raise FileNotFoundError(f"reference image not found: {path}")
83
+ if not _is_supported_image(src):
84
+ raise ValueError(f"unsupported reference image format: {path}")
85
+ name = f"{uuid.uuid4().hex}{src.suffix.lower()}"
86
+ dst = REFERENCE_UPLOAD_DIR / name
87
+ shutil.copy2(src, dst)
88
+ return f"{_reference_image_base_url()}/api/serve-reference/{name}"
89
+
90
+
91
+ def resolve_template_path(template_path: str | None, examples_dir: str, example_file: str | None) -> str:
92
+ if template_path:
93
+ p = Path(template_path)
94
+ if not p.exists():
95
+ raise FileNotFoundError(f"template image not found: {template_path}")
96
+ if not _is_supported_image(p):
97
+ raise ValueError(f"unsupported template image format: {template_path}")
98
+ return str(p)
99
+ if example_file:
100
+ p = Path(examples_dir) / example_file
101
+ if not p.exists():
102
+ raise FileNotFoundError(f"example template not found: {p}")
103
+ if not _is_supported_image(p):
104
+ raise ValueError(f"unsupported example template format: {p}")
105
+ return str(p)
106
+ raise ValueError("Provide either --template or --example-file.")
107
+
108
+
109
+ def list_template_paths(examples_dir: str, bulk_limit: int = 0) -> list[str]:
110
+ p = Path(examples_dir)
111
+ if not p.exists() or not p.is_dir():
112
+ raise FileNotFoundError(f"examples dir not found: {examples_dir}")
113
+ files = sorted(
114
+ [f for f in p.iterdir() if f.is_file() and _is_supported_image(f)],
115
+ key=lambda x: x.name.lower(),
116
+ )
117
+ if not files:
118
+ raise ValueError(f"No supported template images found in: {examples_dir}")
119
+ if bulk_limit > 0:
120
+ files = files[:bulk_limit]
121
+ return [str(f) for f in files]
122
+
123
+
124
+ def scrape_product_image_url(product_url: str) -> tuple[str, dict]:
125
+ data = scrape_product(product_url)
126
+ images = [u.strip() for u in (data.get("product_images") or "").split(",") if u.strip()]
127
+ first = next((u for u in images if u.startswith("http://") or u.startswith("https://")), "")
128
+ if not first:
129
+ raise ValueError("No valid product image URL found after scraping.")
130
+ return first, data
131
+
132
+
133
+ def analyse_images(template_path: str, product_image_url: str, logo_path: str | None = None) -> dict:
134
+ if GEMINI_API_KEY == "YOUR_GEMINI_API_KEY":
135
+ raise RuntimeError("GEMINI_API_KEY is not set.")
136
+ client = genai.Client(api_key=GEMINI_API_KEY)
137
+ template_mime = "image/png" if Path(template_path).suffix.lower() == ".png" else "image/jpeg"
138
+ parts: list = [
139
+ VISION_USER_PROMPT,
140
+ {"inline_data": {"mime_type": template_mime, "data": load_image_as_b64(template_path)}},
141
+ {"file_data": {"mime_type": "image/jpeg", "file_uri": product_image_url}},
142
+ ]
143
+ if logo_path:
144
+ logo_mime = "image/png" if Path(logo_path).suffix.lower() == ".png" else "image/jpeg"
145
+ parts.append({"inline_data": {"mime_type": logo_mime, "data": load_image_as_b64(logo_path)}})
146
+ response = client.models.generate_content(
147
+ model=GEMINI_MODEL,
148
+ contents=parts,
149
+ config=types.GenerateContentConfig(
150
+ temperature=0.35,
151
+ response_mime_type="application/json",
152
+ thinking_config=types.ThinkingConfig(thinking_level="medium"),
153
+ ),
154
+ )
155
+ raw = (response.text or "").strip()
156
+ if raw.startswith("```"):
157
+ raw = raw.strip().strip("`")
158
+ if raw.lower().startswith("json"):
159
+ raw = raw[4:].strip()
160
+ return json.loads(raw)
161
+
162
+
163
+ def build_base_prompt_from_analysis(analysis: dict) -> str:
164
+ direct = (analysis.get("image_generation_prompt") or "").strip()
165
+ product_lock = (
166
+ "CRITICAL PRODUCT LOCK: Keep the exact same product from the reference image. "
167
+ "Do not change product type, silhouette, geometry, metal tone, gemstone colors, gemstone count, "
168
+ "stone shapes, setting style, proportions, or signature details. "
169
+ "No redesign, no substitutions, no style drift."
170
+ )
171
+ logic_lock = (
172
+ "LOGICAL REALISM LOCK: Final creative must be physically and contextually believable. "
173
+ "Jewelry placement must be natural (ring on a finger or realistic display surface), "
174
+ "hand anatomy must be correct, perspective/scale must be coherent, lighting and shadows must match scene geometry, "
175
+ "materials must look real (metal reflectance and gemstone refraction), and text placement must feel intentional/readable. "
176
+ "Avoid impossible poses, floating objects, mismatched reflections, or visually confusing composition."
177
+ )
178
+ if direct:
179
+ return (
180
+ f"{direct} "
181
+ f"{product_lock} "
182
+ f"{logic_lock} "
183
+ "Strictly preserve the template layout and composition one-to-one; "
184
+ "only replace content (product/copy/logo) inside the same structure."
185
+ ).strip()
186
+ synthesized = (
187
+ "Create a premium photorealistic jewelry ad. "
188
+ f"Template guidance: {analysis.get('template_analysis', '')}. "
189
+ f"Product guidance: {analysis.get('product_description', '')}. "
190
+ f"Brand guidance: {analysis.get('brand_info', '')}. "
191
+ "Luxury tone, clear hierarchy, readable text, clean composition."
192
+ )
193
+ neg = (analysis.get("negative_prompt") or "").strip()
194
+ if neg:
195
+ synthesized += f" Avoid: {neg}."
196
+ print(" ⚠️ image_generation_prompt missing; synthesized from analysis.")
197
+ return (
198
+ f"{synthesized} "
199
+ f"{product_lock} "
200
+ f"{logic_lock} "
201
+ "Strictly preserve the template layout and composition one-to-one; "
202
+ "only replace content (product/copy/logo) inside the same structure."
203
+ ).strip()
204
+
205
+
206
+ def generate_with_nano_banana(base_prompt: str, reference_image_urls: list[str], width: int, height: int, num_outputs: int) -> list[str]:
207
+ os.environ["REPLICATE_API_TOKEN"] = REPLICATE_API_KEY
208
+ refs = [u for i, u in enumerate(reference_image_urls) if u and u not in reference_image_urls[:i]]
209
+ urls: list[str] = []
210
+ for _ in range(num_outputs):
211
+ final_url = None
212
+ final_err = "Image generation failed."
213
+ for attempt in range(1, GENERATION_MAX_ATTEMPTS + 1):
214
+ url, err = generate_image_sync(
215
+ prompt=base_prompt,
216
+ model_key=MODEL_KEY,
217
+ width=width,
218
+ height=height,
219
+ reference_image_urls=refs,
220
+ )
221
+ if url and not err:
222
+ final_url = url
223
+ break
224
+ final_err = err or "Image generation returned no URL."
225
+ print(f" ⚠️ Attempt {attempt}/{GENERATION_MAX_ATTEMPTS} failed: {final_err}")
226
+ if attempt < GENERATION_MAX_ATTEMPTS:
227
+ time.sleep(GENERATION_RETRY_DELAY_SEC)
228
+ if not final_url:
229
+ raise RuntimeError(f"Image generation failed after {GENERATION_MAX_ATTEMPTS} attempts: {final_err}")
230
+ urls.append(final_url)
231
+ return urls
232
+
233
+
234
+ def save_image_from_url(url: str, filename: str) -> Path:
235
+ resp = requests.get(url, timeout=60)
236
+ resp.raise_for_status()
237
+ out = OUTPUT_DIR / filename
238
+ out.write_bytes(resp.content)
239
+ return out
240
+
241
+
242
+ def generate_ad_creative(template_path: str, product_url: str, logo_path: str | None, num_outputs: int, width: int, height: int) -> list[Path]:
243
+ print("\n" + "═" * 56)
244
+ print(" AD CREATIVE GENERATOR • Gemini + Nano Banana 2")
245
+ print("═" * 56)
246
+ print(f" 🧩 Template image: {template_path}")
247
+ print(f" 🌍 Reference base URL: {_reference_image_base_url()}")
248
+
249
+ print("\n[0/3] 🌐 Scraping product page …")
250
+ product_image_url, product_data = scrape_product_image_url(product_url)
251
+ print(f" ✅ Product: {product_data.get('product_name', '')}")
252
+ print(f" ✅ Product image: {product_image_url}")
253
+
254
+ template_ref = _publish_local_reference(template_path)
255
+ print(f" ✅ Published template reference: {template_ref}")
256
+ logo_ref = None
257
+ if logo_path:
258
+ logo_ref = _publish_local_reference(logo_path)
259
+ print(f" ✅ Published logo reference: {logo_ref}")
260
+
261
+ print(f"\n[1/3] 🔍 Analysing images + building prompt with {GEMINI_MODEL} …")
262
+ analysis = analyse_images(template_path, product_image_url, logo_path)
263
+ print(" ✅ Analysis complete.")
264
+ base_prompt = build_base_prompt_from_analysis(analysis)
265
+
266
+ ts = int(time.time())
267
+ payload = {
268
+ "analysis": analysis,
269
+ "meta": {
270
+ "product_url": product_url,
271
+ "selected_product_image_url": product_image_url,
272
+ "template_reference_url": template_ref,
273
+ "logo_reference_url": logo_ref,
274
+ "used_template_image": template_path,
275
+ "product_name": product_data.get("product_name", ""),
276
+ "model_key": MODEL_KEY,
277
+ "template_path": template_path,
278
+ "logo_path": logo_path,
279
+ "timestamp": ts,
280
+ },
281
+ }
282
+ analysis_file = OUTPUT_DIR / f"analysis_{ts}.json"
283
+ analysis_file.write_text(json.dumps(payload, indent=2))
284
+ print(" 🧾 Payload:")
285
+ print(json.dumps(payload, indent=2))
286
+ print(f" 📄 Analysis JSON → {analysis_file}")
287
+
288
+ print("\n[3/3] 🚀 Generating with nano-banana-2 …")
289
+ refs = [product_image_url, template_ref]
290
+ if logo_ref:
291
+ refs.append(logo_ref)
292
+ print(f" 📦 Generation references ({len(refs)}): {refs}")
293
+ out_urls = generate_with_nano_banana(base_prompt, refs, width, height, num_outputs)
294
+
295
+ saved: list[Path] = []
296
+ for i, url in enumerate(out_urls, start=1):
297
+ p = save_image_from_url(url, f"ad_creative_{ts}_{i}.png")
298
+ saved.append(p)
299
+ print(f" ✅ Saved → {p}")
300
+
301
+ print("\n" + "═" * 56)
302
+ print(f" ✨ {len(saved)} creative(s) ready in ./{OUTPUT_DIR}/")
303
+ print("═" * 56 + "\n")
304
+ return saved
305
+
306
+
307
+ if __name__ == "__main__":
308
+ import argparse
309
+
310
+ parser = argparse.ArgumentParser(description="Generate ad creatives — Gemini Vision + Nano Banana 2")
311
+ parser.add_argument("--template", default=None, help="Direct template path")
312
+ parser.add_argument("--examples-dir", default="backend/data/creativity_examples", help="Examples dir")
313
+ parser.add_argument("--example-file", default=None, help="Template filename inside examples dir")
314
+ parser.add_argument("--product-url", default="https://amalfa.in/products/thalia-prism-ring", help="Product URL")
315
+ parser.add_argument("--logo", default=None, help="Optional logo path")
316
+ parser.add_argument("--num", type=int, default=1, help="Number of outputs")
317
+ parser.add_argument("--width", type=int, default=1024)
318
+ parser.add_argument("--height", type=int, default=1024)
319
+ parser.add_argument("--bulk-templates", action="store_true", help="Run generation for all templates in --examples-dir")
320
+ parser.add_argument("--bulk-limit", type=int, default=0, help="Optional cap on number of templates in bulk mode")
321
+ args = parser.parse_args()
322
+
323
+ try:
324
+ files: list[Path] = []
325
+ if args.bulk_templates:
326
+ templates = list_template_paths(args.examples_dir, args.bulk_limit)
327
+ print(f"Running bulk mode for {len(templates)} template(s) from {args.examples_dir}")
328
+ failed: list[str] = []
329
+ for idx, template in enumerate(templates, start=1):
330
+ print(f"\n--- [{idx}/{len(templates)}] Template: {template} ---")
331
+ try:
332
+ out = generate_ad_creative(
333
+ template_path=template,
334
+ product_url=args.product_url,
335
+ logo_path=args.logo,
336
+ num_outputs=args.num,
337
+ width=args.width,
338
+ height=args.height,
339
+ )
340
+ files.extend(out)
341
+ except Exception as ex:
342
+ print(f" ❌ Template failed: {template} | {ex}")
343
+ failed.append(template)
344
+ if failed:
345
+ print(f"\nBulk completed with {len(failed)} failed template(s).")
346
+ else:
347
+ print("\nBulk completed with no template failures.")
348
+ else:
349
+ template = resolve_template_path(args.template, args.examples_dir, args.example_file)
350
+ files = generate_ad_creative(
351
+ template_path=template,
352
+ product_url=args.product_url,
353
+ logo_path=args.logo,
354
+ num_outputs=args.num,
355
+ width=args.width,
356
+ height=args.height,
357
+ )
358
+ print("Output files:")
359
+ for p in files:
360
+ print(f" → {p}")
361
+ except Exception as e:
362
+ print(f"\n❌ Flow failed: {e}")
363
+ raise SystemExit(1)
364
+
frontend/src/App.jsx CHANGED
@@ -167,7 +167,11 @@ const THEME_CYCLE_MS = 60000
167
  const CREATIVE_FLOW_OPTIONS = [
168
  { id: 'purely_aesthetic', label: 'Purely aesthetic flow' },
169
  { id: 'cross_vertical', label: 'Cross-vertical inspiration flow' },
170
- { id: 'archetype_rotation', label: 'Archetype rotation flow' },
 
 
 
 
171
  ]
172
 
173
  function authHeaders() {
@@ -176,6 +180,17 @@ function authHeaders() {
176
  return { Authorization: `Bearer ${token}` }
177
  }
178
 
 
 
 
 
 
 
 
 
 
 
 
179
  function LoginPage({ onLogin }) {
180
  const [username, setUsername] = useState('')
181
  const [password, setPassword] = useState('')
@@ -278,6 +293,7 @@ export default function App() {
278
  const [useSmartMatching, setUseSmartMatching] = useState(false) // AI-powered product image matching
279
  const [aspectRatio, setAspectRatio] = useState('1:1') // 1:1 | 16:9 | 9:16 for generated ad images
280
  const [creativeFlow, setCreativeFlow] = useState('purely_aesthetic')
 
281
  const [lastRunCreativeFlow, setLastRunCreativeFlow] = useState(null)
282
  const [targetAudiences, setTargetAudiences] = useState([]) // selected audience segments for analysis/creatives (multi-select)
283
  const [targetAudienceOpen, setTargetAudienceOpen] = useState(false)
@@ -289,6 +305,19 @@ export default function App() {
289
  const prevGeneratingRef = useRef(false)
290
  const [canvaStatus, setCanvaStatus] = useState({ configured: false, connected: false })
291
  const [exportingToCanvaId, setExportingToCanvaId] = useState(null)
 
 
 
 
 
 
 
 
 
 
 
 
 
292
 
293
  // Close target audience dropdown on outside click
294
  useEffect(() => {
@@ -321,6 +350,21 @@ export default function App() {
321
  .catch(() => {})
322
  }, [adCreatives?.length])
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  // Canva status (for export to Canva)
325
  useEffect(() => {
326
  if (!token) return
@@ -351,6 +395,28 @@ export default function App() {
351
  }
352
  }, [token])
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  useEffect(() => {
355
  const id = setInterval(() => {
356
  setTheme((prev) => {
@@ -400,6 +466,123 @@ export default function App() {
400
  prevGeneratingRef.current = generatingAds
401
  }, [generatingAds, generatedAds.length])
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  async function handleRun() {
404
  const trimmed = url.trim()
405
  if (!trimmed) {
@@ -555,41 +738,60 @@ export default function App() {
555
  <p className="hero-desc">
556
  Paste an Amalfa product page URL. We’ll scrape the product, run a deep marketing analysis, and generate ad creative packages (product & no-product) for Meta.
557
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  <div className="input-row">
559
  <input
560
  type="url"
561
  placeholder="https://amalfa.in/products/..."
562
  value={url}
563
  onChange={(e) => setUrl(e.target.value)}
564
- onKeyDown={(e) => e.key === 'Enter' && handleRun()}
565
- disabled={loading}
566
  className="url-input"
567
  />
568
  <button
569
  type="button"
570
- onClick={handleRun}
571
- disabled={loading}
572
  className="btn-run"
573
  >
574
- {loading ? 'Generating…' : 'Generate'}
 
 
575
  </button>
576
  </div>
 
 
577
  <div className="creative-flow-row">
578
- <label className="creative-flow-label">Creative flow</label>
579
- <select
580
- value={creativeFlow}
581
- onChange={(e) => setCreativeFlow(e.target.value)}
582
- disabled={loading}
583
- className="creative-flow-select"
584
- >
585
- {CREATIVE_FLOW_OPTIONS.map((opt) => (
586
- <option key={opt.id} value={opt.id}>
587
- {opt.label}
588
- </option>
589
- ))}
590
- </select>
591
- </div>
592
- <div className="target-audience-row" ref={targetAudienceDropdownRef}>
593
  <span className="target-audience-label">Target audiences (optional)</span>
594
  <div className="target-audience-multiselect">
595
  <button
@@ -642,18 +844,213 @@ export default function App() {
642
  )}
643
  </div>
644
  </div>
645
- {error && <p className="error">{error}</p>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  </section>
647
 
648
- {loading && (
649
  <section className="loading">
650
  <div className="spinner" />
651
- <p className="loading-step">{streamStep || 'Starting…'}</p>
652
  <p className="loading-hint">Results will appear below as each step completes.</p>
653
  </section>
654
  )}
655
 
656
- {(productData || analysis || (adCreatives && adCreatives.length > 0)) && (
657
  <div className="results">
658
  {productData && (
659
  <ProductCard
@@ -910,6 +1307,18 @@ export default function App() {
910
  )}
911
  </div>
912
  )}
 
 
 
 
 
 
 
 
 
 
 
 
913
 
914
  <footer className="footer">
915
  <p>Because love deserves a little extra ❤️</p>
@@ -953,6 +1362,41 @@ export default function App() {
953
  )
954
  }
955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  function ProductCard({ data, selectedReferenceUrls = [], onToggleReference, onImageClick }) {
957
  const imageUrls = (data?.product_images || '')
958
  .split(',')
 
167
  const CREATIVE_FLOW_OPTIONS = [
168
  { id: 'purely_aesthetic', label: 'Purely aesthetic flow' },
169
  { id: 'cross_vertical', label: 'Cross-vertical inspiration flow' },
170
+ { id: 'template_based_creatives', label: 'Template based creatives' },
171
+ ]
172
+ const STUDIO_MODES = [
173
+ { id: 'standard', label: 'Standard flows' },
174
+ { id: 'template', label: 'Template based creatives' },
175
  ]
176
 
177
  function authHeaders() {
 
180
  return { Authorization: `Bearer ${token}` }
181
  }
182
 
183
+ function toSameOriginDisplayUrl(url) {
184
+ if (!url || typeof window === 'undefined') return url
185
+ try {
186
+ const u = new URL(url, window.location.origin)
187
+ if (u.origin !== window.location.origin) return `${u.pathname}${u.search}`
188
+ return url
189
+ } catch {
190
+ return url
191
+ }
192
+ }
193
+
194
  function LoginPage({ onLogin }) {
195
  const [username, setUsername] = useState('')
196
  const [password, setPassword] = useState('')
 
293
  const [useSmartMatching, setUseSmartMatching] = useState(false) // AI-powered product image matching
294
  const [aspectRatio, setAspectRatio] = useState('1:1') // 1:1 | 16:9 | 9:16 for generated ad images
295
  const [creativeFlow, setCreativeFlow] = useState('purely_aesthetic')
296
+ const [studioMode, setStudioMode] = useState('standard')
297
  const [lastRunCreativeFlow, setLastRunCreativeFlow] = useState(null)
298
  const [targetAudiences, setTargetAudiences] = useState([]) // selected audience segments for analysis/creatives (multi-select)
299
  const [targetAudienceOpen, setTargetAudienceOpen] = useState(false)
 
305
  const prevGeneratingRef = useRef(false)
306
  const [canvaStatus, setCanvaStatus] = useState({ configured: false, connected: false })
307
  const [exportingToCanvaId, setExportingToCanvaId] = useState(null)
308
+ const [templateSource, setTemplateSource] = useState('examples') // examples | upload
309
+ const [templateExamples, setTemplateExamples] = useState([])
310
+ const [templateImageUrl, setTemplateImageUrl] = useState('')
311
+ const [selectedTemplateUrls, setSelectedTemplateUrls] = useState([])
312
+ const [templateLogoUrl, setTemplateLogoUrl] = useState('/api/logo')
313
+ const [templateResults, setTemplateResults] = useState([])
314
+ const [templateMeta, setTemplateMeta] = useState(null)
315
+ const [templateAnalysis, setTemplateAnalysis] = useState(null)
316
+ const [templateLoading, setTemplateLoading] = useState(false)
317
+ const [templateScrapeLoading, setTemplateScrapeLoading] = useState(false)
318
+ const [templateSelectedProductUrls, setTemplateSelectedProductUrls] = useState([])
319
+ const [templateError, setTemplateError] = useState(null)
320
+ const [templateUploadLoading, setTemplateUploadLoading] = useState(false)
321
 
322
  // Close target audience dropdown on outside click
323
  useEffect(() => {
 
350
  .catch(() => {})
351
  }, [adCreatives?.length])
352
 
353
+ useEffect(() => {
354
+ if (!token || studioMode !== 'template') return
355
+ fetch(`${API_BASE}/image-models`, { headers: authHeaders() })
356
+ .then((r) => {
357
+ if (r.status === 401) { localStorage.removeItem(AUTH_TOKEN_KEY); setToken(null); return null }
358
+ return r.ok ? r.json() : null
359
+ })
360
+ .then((data) => {
361
+ if (!data) return
362
+ setImageModels(data.models || [])
363
+ if (data.default) setSelectedImageModel((prev) => prev || data.default)
364
+ })
365
+ .catch(() => {})
366
+ }, [token, studioMode])
367
+
368
  // Canva status (for export to Canva)
369
  useEffect(() => {
370
  if (!token) return
 
395
  }
396
  }, [token])
397
 
398
+ useEffect(() => {
399
+ if (!token || studioMode !== 'template') return
400
+ fetch(`${API_BASE}/template-examples`, { headers: authHeaders() })
401
+ .then((r) => {
402
+ if (r.status === 401) {
403
+ localStorage.removeItem(AUTH_TOKEN_KEY)
404
+ setToken(null)
405
+ return null
406
+ }
407
+ return r.ok ? r.json() : null
408
+ })
409
+ .then((data) => {
410
+ const list = data?.templates || []
411
+ setTemplateExamples(list)
412
+ if (!templateImageUrl && list.length > 0 && templateSource === 'examples') {
413
+ setTemplateImageUrl(list[0].url)
414
+ setSelectedTemplateUrls([list[0].url])
415
+ }
416
+ })
417
+ .catch(() => {})
418
+ }, [token, studioMode, templateSource])
419
+
420
  useEffect(() => {
421
  const id = setInterval(() => {
422
  setTheme((prev) => {
 
466
  prevGeneratingRef.current = generatingAds
467
  }, [generatingAds, generatedAds.length])
468
 
469
+ async function uploadReferenceImage(file) {
470
+ const form = new FormData()
471
+ form.append('file', file, file.name || 'image.png')
472
+ const res = await fetch('/api/upload-reference', {
473
+ method: 'POST',
474
+ headers: authHeaders(),
475
+ body: form,
476
+ })
477
+ const data = await res.json().catch(() => ({}))
478
+ if (res.status === 401) {
479
+ localStorage.removeItem(AUTH_TOKEN_KEY)
480
+ setToken(null)
481
+ throw new Error('Session expired')
482
+ }
483
+ if (!res.ok) throw new Error(data.detail || 'Upload failed')
484
+ return data.url
485
+ }
486
+
487
+ async function handleTemplateFileChange(e) {
488
+ const file = e.target?.files?.[0]
489
+ e.target.value = ''
490
+ if (!file || !file.type.startsWith('image/')) return
491
+ setTemplateError(null)
492
+ setTemplateUploadLoading(true)
493
+ try {
494
+ const url = await uploadReferenceImage(file)
495
+ setTemplateImageUrl(url)
496
+ setSelectedTemplateUrls([url])
497
+ setTemplateSource('upload')
498
+ } catch (err) {
499
+ setTemplateError(err.message || 'Template upload failed')
500
+ } finally {
501
+ setTemplateUploadLoading(false)
502
+ }
503
+ }
504
+
505
+ async function handleTemplateRun() {
506
+ const trimmed = url.trim()
507
+ if (!trimmed) {
508
+ setTemplateError('Please enter an Amalfa product URL.')
509
+ return
510
+ }
511
+ const templateUrls = (selectedTemplateUrls || []).filter(Boolean)
512
+ if (templateUrls.length === 0) {
513
+ setTemplateError('Please select or upload a template image.')
514
+ return
515
+ }
516
+ setTemplateError(null)
517
+ setTemplateLoading(true)
518
+ setTemplateResults([])
519
+ setTemplateMeta(null)
520
+ setTemplateAnalysis(null)
521
+ try {
522
+ const imageUrls = (productData?.product_images || '').split(',').map((s) => s.trim()).filter(Boolean)
523
+ const selectedProductUrls = (templateSelectedProductUrls || []).filter((u) => imageUrls.includes(u)).slice(0, 3)
524
+ const res = await fetch(`${API_BASE}/template-creatives/run`, {
525
+ method: 'POST',
526
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
527
+ body: JSON.stringify({
528
+ product_url: trimmed,
529
+ template_image_urls: templateUrls,
530
+ product_image_urls: selectedProductUrls.length ? selectedProductUrls : null,
531
+ logo_image_url: templateLogoUrl || null,
532
+ model_key: selectedImageModel,
533
+ num_outputs: 1,
534
+ aspect_ratio: aspectRatio,
535
+ }),
536
+ })
537
+ if (res.status === 401) {
538
+ localStorage.removeItem(AUTH_TOKEN_KEY)
539
+ setToken(null)
540
+ return
541
+ }
542
+ const data = await res.json().catch(() => ({}))
543
+ if (!res.ok) throw new Error(data.detail || res.statusText)
544
+ setTemplateResults(data.images || [])
545
+ setTemplateMeta(data.meta || null)
546
+ setTemplateAnalysis(data.analysis || null)
547
+ setProductData(data.product_data || null)
548
+ } catch (e) {
549
+ setTemplateError(e.message || 'Template generation failed')
550
+ } finally {
551
+ setTemplateLoading(false)
552
+ }
553
+ }
554
+
555
+ async function handleTemplateScrapeProductImages() {
556
+ const trimmed = url.trim()
557
+ if (!trimmed) {
558
+ setTemplateError('Please enter an Amalfa product URL.')
559
+ return
560
+ }
561
+ setTemplateError(null)
562
+ setTemplateScrapeLoading(true)
563
+ try {
564
+ const res = await fetch(`${API_BASE}/scrape`, {
565
+ method: 'POST',
566
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
567
+ body: JSON.stringify({ url: trimmed }),
568
+ })
569
+ if (res.status === 401) {
570
+ localStorage.removeItem(AUTH_TOKEN_KEY)
571
+ setToken(null)
572
+ return
573
+ }
574
+ const data = await res.json().catch(() => ({}))
575
+ if (!res.ok) throw new Error(data.detail || res.statusText)
576
+ setProductData(data)
577
+ const imageUrls = (data?.product_images || '').split(',').map((s) => s.trim()).filter(Boolean)
578
+ setTemplateSelectedProductUrls(imageUrls.length ? [imageUrls[0]] : [])
579
+ } catch (e) {
580
+ setTemplateError(e.message || 'Failed to scrape product images')
581
+ } finally {
582
+ setTemplateScrapeLoading(false)
583
+ }
584
+ }
585
+
586
  async function handleRun() {
587
  const trimmed = url.trim()
588
  if (!trimmed) {
 
738
  <p className="hero-desc">
739
  Paste an Amalfa product page URL. We’ll scrape the product, run a deep marketing analysis, and generate ad creative packages (product & no-product) for Meta.
740
  </p>
741
+ <div className="studio-mode-switch" role="tablist" aria-label="Studio mode">
742
+ {STUDIO_MODES.map((opt) => (
743
+ <button
744
+ key={opt.id}
745
+ type="button"
746
+ role="tab"
747
+ aria-selected={studioMode === opt.id}
748
+ className={`studio-mode-btn ${studioMode === opt.id ? 'studio-mode-btn--active' : ''}`}
749
+ onClick={() => setStudioMode(opt.id)}
750
+ disabled={loading || templateLoading}
751
+ >
752
+ {opt.label}
753
+ </button>
754
+ ))}
755
+ </div>
756
  <div className="input-row">
757
  <input
758
  type="url"
759
  placeholder="https://amalfa.in/products/..."
760
  value={url}
761
  onChange={(e) => setUrl(e.target.value)}
762
+ onKeyDown={(e) => e.key === 'Enter' && (studioMode === 'template' ? handleTemplateScrapeProductImages() : handleRun())}
763
+ disabled={loading || templateLoading || templateScrapeLoading}
764
  className="url-input"
765
  />
766
  <button
767
  type="button"
768
+ onClick={studioMode === 'template' ? handleTemplateScrapeProductImages : handleRun}
769
+ disabled={loading || templateLoading || templateScrapeLoading}
770
  className="btn-run"
771
  >
772
+ {studioMode === 'template'
773
+ ? (templateScrapeLoading ? 'Scraping…' : 'Scrape')
774
+ : ((loading || templateLoading) ? 'Generating…' : 'Generate')}
775
  </button>
776
  </div>
777
+ {studioMode === 'standard' ? (
778
+ <>
779
  <div className="creative-flow-row">
780
+ <label className="creative-flow-label">Creative flow</label>
781
+ <select
782
+ value={creativeFlow}
783
+ onChange={(e) => setCreativeFlow(e.target.value)}
784
+ disabled={loading}
785
+ className="creative-flow-select"
786
+ >
787
+ {CREATIVE_FLOW_OPTIONS.filter((f) => f.id !== 'template_based_creatives').map((opt) => (
788
+ <option key={opt.id} value={opt.id}>
789
+ {opt.label}
790
+ </option>
791
+ ))}
792
+ </select>
793
+ </div>
794
+ <div className="target-audience-row" ref={targetAudienceDropdownRef}>
795
  <span className="target-audience-label">Target audiences (optional)</span>
796
  <div className="target-audience-multiselect">
797
  <button
 
844
  )}
845
  </div>
846
  </div>
847
+ {error && <p className="error">{error}</p>}
848
+ </>
849
+ ) : (
850
+ <div className="template-mode-panel">
851
+ <div className="template-step-card">
852
+ <div className="template-step-head">
853
+ <span className="template-step-badge">1</span>
854
+ <div>
855
+ <h3>Scrape product references</h3>
856
+ <p>Use the product URL above, then fetch product images for selection.</p>
857
+ </div>
858
+ </div>
859
+ <div className="creative-flow-row">
860
+ <span className="analysis-preview-meta">
861
+ Selected: {templateSelectedProductUrls.length}/3 product references
862
+ </span>
863
+ </div>
864
+ {(productData?.product_images || '').trim() && (
865
+ <ProductCard
866
+ data={productData}
867
+ selectedReferenceUrls={templateSelectedProductUrls}
868
+ onToggleReference={(imgUrl) => {
869
+ setTemplateSelectedProductUrls((prev) => {
870
+ const has = prev.includes(imgUrl)
871
+ if (has) return prev.filter((u) => u !== imgUrl)
872
+ if (prev.length >= 3) return prev
873
+ return [...prev, imgUrl]
874
+ })
875
+ }}
876
+ onImageClick={(src, alt) => setLightboxImage({ src, alt })}
877
+ />
878
+ )}
879
+ </div>
880
+
881
+ <div className="template-step-card">
882
+ <div className="template-step-head">
883
+ <span className="template-step-badge">2</span>
884
+ <div>
885
+ <h3>Choose templates</h3>
886
+ <p>Select one or more templates to guide the generated creative.</p>
887
+ </div>
888
+ </div>
889
+ <div className="creative-flow-row">
890
+ <label className="creative-flow-label">Template source</label>
891
+ <select
892
+ value={templateSource}
893
+ onChange={(e) => {
894
+ const src = e.target.value
895
+ setTemplateSource(src)
896
+ if (src === 'examples' && templateExamples.length > 0) {
897
+ setTemplateImageUrl(templateExamples[0].url)
898
+ setSelectedTemplateUrls([templateExamples[0].url])
899
+ }
900
+ }}
901
+ disabled={templateLoading}
902
+ className="creative-flow-select"
903
+ >
904
+ <option value="examples">Example gallery</option>
905
+ <option value="upload">Upload template</option>
906
+ </select>
907
+ <span className="analysis-preview-meta">
908
+ Selected templates: {selectedTemplateUrls.length}
909
+ </span>
910
+ </div>
911
+ {templateSource === 'examples' ? (
912
+ <div className="template-examples-grid">
913
+ <div className="template-selection-actions">
914
+ <button
915
+ type="button"
916
+ className="target-audience-action-btn"
917
+ onClick={() => setSelectedTemplateUrls(templateExamples.map((t) => t.url))}
918
+ disabled={templateLoading || templateExamples.length === 0}
919
+ >
920
+ Select all
921
+ </button>
922
+ <button
923
+ type="button"
924
+ className="target-audience-action-btn"
925
+ onClick={() => {
926
+ setSelectedTemplateUrls([])
927
+ setTemplateImageUrl('')
928
+ }}
929
+ disabled={templateLoading || selectedTemplateUrls.length === 0}
930
+ >
931
+ Clear
932
+ </button>
933
+ </div>
934
+ {templateExamples.map((t) => (
935
+ <button
936
+ key={t.url}
937
+ type="button"
938
+ className={`template-example-card ${selectedTemplateUrls.includes(t.url) ? 'template-example-card--active' : ''}`}
939
+ onClick={() => {
940
+ setSelectedTemplateUrls((prev) => {
941
+ const has = prev.includes(t.url)
942
+ const next = has ? prev.filter((u) => u !== t.url) : [...prev, t.url]
943
+ setTemplateImageUrl(next[0] || '')
944
+ return next
945
+ })
946
+ }}
947
+ disabled={templateLoading}
948
+ >
949
+ <img src={toSameOriginDisplayUrl(t.url)} alt={t.name} className="template-example-img" />
950
+ <span className="template-example-name">{t.name}</span>
951
+ </button>
952
+ ))}
953
+ </div>
954
+ ) : (
955
+ <div className="creative-flow-row template-upload-row">
956
+ <label className="creative-flow-label">Upload template image</label>
957
+ <label className="template-file-btn">
958
+ <input
959
+ type="file"
960
+ accept="image/*"
961
+ onChange={handleTemplateFileChange}
962
+ disabled={templateLoading || templateUploadLoading}
963
+ />
964
+ {templateUploadLoading ? 'Uploading...' : 'Choose template image'}
965
+ </label>
966
+ </div>
967
+ )}
968
+ </div>
969
+
970
+ <div className="template-step-card">
971
+ <div className="template-step-head">
972
+ <span className="template-step-badge">3</span>
973
+ <div>
974
+ <h3>Review references and generate</h3>
975
+ <p>Confirm selected references and run one final creative.</p>
976
+ </div>
977
+ </div>
978
+ <div className="template-controls-row">
979
+ <div className="template-control-item">
980
+ <label className="creative-flow-label">Image model</label>
981
+ <select
982
+ value={selectedImageModel}
983
+ onChange={(e) => setSelectedImageModel(e.target.value)}
984
+ disabled={templateLoading}
985
+ className="creative-flow-select"
986
+ >
987
+ {imageModels.length > 0 ? (
988
+ imageModels.map((m) => (
989
+ <option key={m.key} value={m.key}>
990
+ {m.label || m.key}
991
+ </option>
992
+ ))
993
+ ) : (
994
+ <option value="nano-banana-2">Nano Banana 2 (Replicate)</option>
995
+ )}
996
+ </select>
997
+ </div>
998
+ <div className="template-control-item">
999
+ <label className="creative-flow-label">Outputs</label>
1000
+ <span className="template-control-value">
1001
+ {Math.max(1, selectedTemplateUrls.length) * Math.max(1, templateSelectedProductUrls.length)} image
1002
+ {Math.max(1, selectedTemplateUrls.length) * Math.max(1, templateSelectedProductUrls.length) > 1 ? 's' : ''}
1003
+ </span>
1004
+ </div>
1005
+ <div className="template-control-item">
1006
+ <label className="creative-flow-label">Aspect ratio</label>
1007
+ <select
1008
+ value={aspectRatio}
1009
+ onChange={(e) => setAspectRatio(e.target.value)}
1010
+ disabled={templateLoading}
1011
+ className="creative-flow-select"
1012
+ >
1013
+ <option value="1:1">1:1</option>
1014
+ <option value="16:9">16:9</option>
1015
+ <option value="9:16">9:16</option>
1016
+ </select>
1017
+ </div>
1018
+ </div>
1019
+ <div className="template-summary-row">
1020
+ <span className="template-summary-chip">Templates selected: {selectedTemplateUrls.length}</span>
1021
+ <span className="template-summary-chip">Product refs: {templateSelectedProductUrls.length}/3</span>
1022
+ <span className="template-summary-chip">
1023
+ Expected outputs: {Math.max(1, selectedTemplateUrls.length) * Math.max(1, templateSelectedProductUrls.length)}
1024
+ </span>
1025
+ </div>
1026
+ <div className="template-generate-row">
1027
+ <button
1028
+ type="button"
1029
+ className="btn-run"
1030
+ onClick={handleTemplateRun}
1031
+ disabled={templateLoading}
1032
+ >
1033
+ {templateLoading ? 'Generating…' : 'Generate Template Creative'}
1034
+ </button>
1035
+ </div>
1036
+ </div>
1037
+ {templateUploadLoading && (
1038
+ <p className="loading-step">Uploading reference image…</p>
1039
+ )}
1040
+ {templateError && <p className="error">{templateError}</p>}
1041
+ </div>
1042
+ )}
1043
  </section>
1044
 
1045
+ {(loading || templateLoading) && (
1046
  <section className="loading">
1047
  <div className="spinner" />
1048
+ <p className="loading-step">{studioMode === 'template' ? 'Generating template based creatives…' : (streamStep || 'Starting…')}</p>
1049
  <p className="loading-hint">Results will appear below as each step completes.</p>
1050
  </section>
1051
  )}
1052
 
1053
+ {studioMode === 'standard' && (productData || analysis || (adCreatives && adCreatives.length > 0)) && (
1054
  <div className="results">
1055
  {productData && (
1056
  <ProductCard
 
1307
  )}
1308
  </div>
1309
  )}
1310
+ {studioMode === 'template' && (
1311
+ <div className="results">
1312
+ {templateResults && templateResults.length > 0 && (
1313
+ <TemplateResultsSection
1314
+ images={templateResults}
1315
+ meta={templateMeta}
1316
+ analysis={templateAnalysis}
1317
+ onImageClick={(src, alt) => setLightboxImage({ src, alt })}
1318
+ />
1319
+ )}
1320
+ </div>
1321
+ )}
1322
 
1323
  <footer className="footer">
1324
  <p>Because love deserves a little extra ❤️</p>
 
1362
  )
1363
  }
1364
 
1365
+ function TemplateResultsSection({ images = [], meta = null, analysis = null, onImageClick }) {
1366
+ if (!images?.length) return null
1367
+ return (
1368
+ <section className="card creatives-card template-results-card">
1369
+ <h2>Template based creatives ({images.length})</h2>
1370
+ {meta?.product_name && (
1371
+ <p className="creatives-flow-badge">Product: {meta.product_name}</p>
1372
+ )}
1373
+ <div className="template-results-grid">
1374
+ {images.map((url, idx) => (
1375
+ <div key={`${url}-${idx}`} className="template-result-item">
1376
+ <div className="template-result-media">
1377
+ <img
1378
+ src={url}
1379
+ alt={`Template creative ${idx + 1}`}
1380
+ className={`template-result-img ${onImageClick ? 'img-expandable' : ''}`}
1381
+ onClick={() => onImageClick?.(url, `Template creative ${idx + 1}`)}
1382
+ />
1383
+ </div>
1384
+ <div className="template-result-meta">
1385
+ <span className="template-result-id">Creative {idx + 1}</span>
1386
+ </div>
1387
+ </div>
1388
+ ))}
1389
+ </div>
1390
+ {analysis && (
1391
+ <details className="analysis-card" style={{ marginTop: '1rem' }}>
1392
+ <summary>Show analysis JSON</summary>
1393
+ <pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(analysis, null, 2)}</pre>
1394
+ </details>
1395
+ )}
1396
+ </section>
1397
+ )
1398
+ }
1399
+
1400
  function ProductCard({ data, selectedReferenceUrls = [], onToggleReference, onImageClick }) {
1401
  const imageUrls = (data?.product_images || '')
1402
  .split(',')
frontend/src/index.css CHANGED
@@ -409,6 +409,50 @@ a:hover {
409
  line-height: 1.6;
410
  }
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  .input-row {
413
  display: flex;
414
  gap: 0.75rem;
@@ -449,6 +493,271 @@ a:hover {
449
  cursor: not-allowed;
450
  }
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  .target-audience-row {
453
  display: flex;
454
  align-items: center;
 
409
  line-height: 1.6;
410
  }
411
 
412
+ .studio-mode-switch {
413
+ display: inline-flex;
414
+ align-items: center;
415
+ gap: 0.35rem;
416
+ padding: 0.3rem;
417
+ border: 1px solid var(--border);
418
+ border-radius: 999px;
419
+ background: var(--surface-soft);
420
+ margin-bottom: 1rem;
421
+ }
422
+
423
+ .studio-mode-btn {
424
+ border: 0;
425
+ background: transparent;
426
+ color: var(--text-soft);
427
+ padding: 0.55rem 1rem;
428
+ border-radius: 999px;
429
+ font-size: 0.86rem;
430
+ font-weight: 600;
431
+ letter-spacing: 0.01em;
432
+ cursor: pointer;
433
+ transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
434
+ }
435
+
436
+ .studio-mode-btn:hover:not(:disabled) {
437
+ color: var(--text);
438
+ }
439
+
440
+ .studio-mode-btn--active {
441
+ background: linear-gradient(135deg, var(--accent), var(--accent-hover));
442
+ color: #fff;
443
+ box-shadow: none;
444
+ }
445
+
446
+ .studio-mode-btn--active:hover:not(:disabled) {
447
+ color: #fff;
448
+ background: linear-gradient(135deg, var(--accent), var(--accent-hover));
449
+ }
450
+
451
+ .studio-mode-btn:disabled {
452
+ opacity: 0.55;
453
+ cursor: not-allowed;
454
+ }
455
+
456
  .input-row {
457
  display: flex;
458
  gap: 0.75rem;
 
493
  cursor: not-allowed;
494
  }
495
 
496
+ .template-mode-panel {
497
+ margin-top: 1rem;
498
+ padding: 0;
499
+ border: 0;
500
+ border-radius: 0;
501
+ background: transparent;
502
+ }
503
+
504
+ .template-step-card {
505
+ background: var(--surface);
506
+ border: 1px solid var(--border);
507
+ border-radius: 12px;
508
+ padding: 0.9rem;
509
+ margin-top: 0.8rem;
510
+ }
511
+
512
+ .template-step-head {
513
+ display: flex;
514
+ gap: 0.65rem;
515
+ align-items: flex-start;
516
+ }
517
+
518
+ .template-step-head h3 {
519
+ margin: 0;
520
+ font-size: 0.95rem;
521
+ }
522
+
523
+ .template-step-head p {
524
+ margin: 0.2rem 0 0;
525
+ color: var(--text-soft);
526
+ font-size: 0.8rem;
527
+ }
528
+
529
+ .template-step-badge {
530
+ width: 24px;
531
+ height: 24px;
532
+ border-radius: 999px;
533
+ display: inline-flex;
534
+ align-items: center;
535
+ justify-content: center;
536
+ font-size: 0.78rem;
537
+ font-weight: 700;
538
+ color: #fff;
539
+ background: linear-gradient(135deg, var(--accent), var(--accent-hover));
540
+ flex-shrink: 0;
541
+ }
542
+
543
+ .template-examples-grid {
544
+ margin-top: 0.75rem;
545
+ display: grid;
546
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
547
+ gap: 0.75rem;
548
+ }
549
+
550
+ .template-selection-actions {
551
+ grid-column: 1 / -1;
552
+ display: flex;
553
+ gap: 0.5rem;
554
+ }
555
+
556
+ .template-example-card {
557
+ border: 1px solid var(--border);
558
+ border-radius: 10px;
559
+ padding: 0.35rem;
560
+ background: var(--surface);
561
+ display: flex;
562
+ flex-direction: column;
563
+ gap: 0.35rem;
564
+ cursor: pointer;
565
+ transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
566
+ }
567
+
568
+ .template-example-card:hover:not(:disabled) {
569
+ transform: translateY(-2px);
570
+ border-color: var(--accent);
571
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14);
572
+ }
573
+
574
+ .template-example-card--active {
575
+ border-color: var(--accent);
576
+ box-shadow: 0 0 0 2px rgba(var(--accent-focus, 100, 116, 139), 0.3);
577
+ }
578
+
579
+ .template-example-card:disabled {
580
+ opacity: 0.7;
581
+ cursor: not-allowed;
582
+ }
583
+
584
+ .template-example-img {
585
+ width: 100%;
586
+ aspect-ratio: 1;
587
+ object-fit: cover;
588
+ border-radius: 8px;
589
+ }
590
+
591
+ .template-example-name {
592
+ font-size: 0.74rem;
593
+ color: var(--text-soft);
594
+ text-align: left;
595
+ white-space: nowrap;
596
+ overflow: hidden;
597
+ text-overflow: ellipsis;
598
+ padding: 0 0.15rem 0.2rem;
599
+ }
600
+
601
+ .template-upload-row {
602
+ justify-content: space-between;
603
+ }
604
+
605
+ .template-file-btn {
606
+ position: relative;
607
+ display: inline-flex;
608
+ align-items: center;
609
+ justify-content: center;
610
+ padding: 0.55rem 0.9rem;
611
+ border-radius: 8px;
612
+ border: 1px solid transparent;
613
+ background: var(--accent);
614
+ color: #fff;
615
+ font-weight: 600;
616
+ font-size: 0.84rem;
617
+ cursor: pointer;
618
+ }
619
+
620
+ .template-file-btn--secondary {
621
+ background: var(--surface);
622
+ color: var(--text);
623
+ border-color: var(--border);
624
+ }
625
+
626
+ .template-file-btn input {
627
+ position: absolute;
628
+ inset: 0;
629
+ opacity: 0;
630
+ cursor: pointer;
631
+ }
632
+
633
+ .template-controls-row {
634
+ display: grid;
635
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
636
+ gap: 0.75rem 1rem;
637
+ align-items: end;
638
+ }
639
+
640
+ .template-controls-row .creative-flow-row {
641
+ margin-top: 0.4rem;
642
+ }
643
+
644
+ .template-controls-row .creative-flow-select {
645
+ min-width: 130px;
646
+ }
647
+
648
+ .template-control-item {
649
+ display: flex;
650
+ align-items: center;
651
+ gap: 0.65rem;
652
+ }
653
+
654
+ .template-control-value {
655
+ font-size: 0.85rem;
656
+ color: var(--text);
657
+ background: var(--surface-soft);
658
+ border: 1px solid var(--border);
659
+ border-radius: 999px;
660
+ padding: 0.25rem 0.55rem;
661
+ }
662
+
663
+ .template-preview-row {
664
+ margin-top: 0.5rem;
665
+ display: grid;
666
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
667
+ gap: 0.75rem;
668
+ }
669
+
670
+ .template-generate-row {
671
+ margin-top: 0.9rem;
672
+ display: flex;
673
+ justify-content: flex-end;
674
+ }
675
+
676
+ .template-summary-row {
677
+ margin-top: 0.65rem;
678
+ display: flex;
679
+ gap: 0.5rem;
680
+ flex-wrap: wrap;
681
+ }
682
+
683
+ .template-summary-chip {
684
+ font-size: 0.78rem;
685
+ color: var(--text-soft);
686
+ background: var(--surface-soft);
687
+ border: 1px solid var(--border);
688
+ border-radius: 999px;
689
+ padding: 0.28rem 0.6rem;
690
+ }
691
+
692
+ .template-results-card {
693
+ overflow: hidden;
694
+ }
695
+
696
+ .template-results-grid {
697
+ display: grid;
698
+ grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
699
+ gap: 1rem;
700
+ justify-content: start;
701
+ }
702
+
703
+ .template-result-item {
704
+ border: 1px solid var(--border);
705
+ border-radius: 12px;
706
+ overflow: hidden;
707
+ background: var(--surface);
708
+ }
709
+
710
+ .template-result-media {
711
+ background: var(--surface-soft);
712
+ min-height: 280px;
713
+ max-height: 420px;
714
+ display: flex;
715
+ align-items: center;
716
+ justify-content: center;
717
+ padding: 0.5rem;
718
+ }
719
+
720
+ .template-result-img {
721
+ max-width: 100%;
722
+ max-height: 400px;
723
+ width: auto;
724
+ height: auto;
725
+ display: block;
726
+ border-radius: 8px;
727
+ }
728
+
729
+ .template-result-meta {
730
+ padding: 0.55rem 0.75rem;
731
+ border-top: 1px solid var(--border-soft);
732
+ }
733
+
734
+ .template-result-id {
735
+ font-size: 0.82rem;
736
+ color: var(--text-soft);
737
+ font-weight: 600;
738
+ }
739
+
740
+ .template-preview-card {
741
+ background: var(--surface);
742
+ border: 1px solid var(--border);
743
+ border-radius: 10px;
744
+ padding: 0.5rem;
745
+ }
746
+
747
+ .template-preview-label {
748
+ display: inline-block;
749
+ margin-bottom: 0.35rem;
750
+ font-size: 0.76rem;
751
+ color: var(--text-soft);
752
+ }
753
+
754
+ .template-preview-img {
755
+ width: 100%;
756
+ aspect-ratio: 1;
757
+ object-fit: cover;
758
+ border-radius: 8px;
759
+ }
760
+
761
  .target-audience-row {
762
  display: flex;
763
  align-items: center;