Spaces:
Sleeping
Sleeping
Commit ·
4c69ac5
1
Parent(s): 6caa461
added a new flow
Browse files- .gitignore +2 -0
- backend/app/analysis_flows.py +2 -232
- backend/app/main.py +272 -52
- backend/app/template_flow.py +213 -0
- backend/data/creativity_examples/036.png +0 -3
- backend/data/creativity_examples/CDDDAD88-3F75-4EE2-A61D-C70690B11D4F.jpg +0 -3
- backend/data/creativity_examples/image (10).png +0 -3
- fourth_flow.py +364 -0
- frontend/src/App.jsx +469 -25
- frontend/src/index.css +309 -0
.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
|
| 3 |
-
|
| 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
|
| 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 |
-
|
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ==
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ==
|
| 460 |
-
yield _sse_message({"event": "step", "step": "analysis", "message": "Running
|
| 461 |
-
analysis_or_strategies =
|
| 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 =
|
| 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 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 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 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 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
|
backend/data/creativity_examples/CDDDAD88-3F75-4EE2-A61D-C70690B11D4F.jpg
DELETED
Git LFS Details
|
backend/data/creativity_examples/image (10).png
DELETED
Git LFS Details
|
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: '
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
| 575 |
</button>
|
| 576 |
</div>
|
|
|
|
|
|
|
| 577 |
<div className="creative-flow-row">
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|