| """ |
| Ad Creative Generator (Fourth Flow) |
| ----------------------------------- |
| Flow: |
| 1) Scrape product URL to fetch product image |
| 2) Run one Gemini vision call for analysis + generation prompt |
| 3) Generate image via nano-banana-2 with product/template/logo references |
| 4) Save creative + analysis payload to output_creatives/ |
| """ |
|
|
| import base64 |
| import json |
| import os |
| import shutil |
| import sys |
| import time |
| import uuid |
| from pathlib import Path |
|
|
| import requests |
| from dotenv import load_dotenv |
| from google import genai |
| from google.genai import types |
|
|
| load_dotenv() |
|
|
| |
| BACKEND_DIR = Path(__file__).resolve().parent / "backend" |
| if str(BACKEND_DIR) not in sys.path: |
| sys.path.insert(0, str(BACKEND_DIR)) |
|
|
| from app.replicate_image import generate_image_sync |
| from app.scraper import scrape_product |
|
|
| GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "YOUR_GEMINI_API_KEY") |
| GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.1-pro-preview") |
| MODEL_KEY = "nano-banana-2" |
| REPLICATE_API_KEY = os.getenv("REPLICATE_API_KEY") or os.getenv("REPLICATE_API_TOKEN") or "YOUR_REPLICATE_API_KEY" |
| GENERATION_MAX_ATTEMPTS = 3 |
| GENERATION_RETRY_DELAY_SEC = 4 |
|
|
| OUTPUT_DIR = Path("output_creatives") |
| OUTPUT_DIR.mkdir(exist_ok=True) |
| REFERENCE_UPLOAD_DIR = BACKEND_DIR / "reference_uploads" |
| REFERENCE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) |
|
|
|
|
| VISION_USER_PROMPT = """You are an expert creative director and ad-tech specialist. |
| Analyse these images and return ONLY valid JSON with exactly these keys: |
| - template_analysis |
| - product_description |
| - brand_info |
| - image_generation_prompt |
| - negative_prompt |
| |
| Use first image as template/layout reference, second image as product, third image (if present) as logo/brand cue. |
| CRITICAL: Treat the template like a copy-paste layout lock. |
| - Keep the same composition, block structure, spacing, framing, and text placement zones as template. |
| - Keep the same visual style direction (editorial/commercial look), not a new design. |
| - Only change the content inside the template: product subject, brand/logo presence, and copy text. |
| - Do NOT invent a different layout or scene format. |
| |
| Make image_generation_prompt optimized for premium photorealistic jewelry ad output""" |
|
|
|
|
| def load_image_as_b64(path: str) -> str: |
| with open(path, "rb") as f: |
| return base64.b64encode(f.read()).decode("utf-8") |
|
|
|
|
| def _is_supported_image(path: Path) -> bool: |
| return path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"} |
|
|
|
|
| def _reference_image_base_url() -> str: |
| return (os.getenv("BASE_URL") or "http://localhost:8002").rstrip("/") |
|
|
|
|
| def _publish_local_reference(path: str) -> str: |
| src = Path(path) |
| if not src.exists(): |
| raise FileNotFoundError(f"reference image not found: {path}") |
| if not _is_supported_image(src): |
| raise ValueError(f"unsupported reference image format: {path}") |
| name = f"{uuid.uuid4().hex}{src.suffix.lower()}" |
| dst = REFERENCE_UPLOAD_DIR / name |
| shutil.copy2(src, dst) |
| return f"{_reference_image_base_url()}/api/serve-reference/{name}" |
|
|
|
|
| def resolve_template_path(template_path: str | None, examples_dir: str, example_file: str | None) -> str: |
| if template_path: |
| p = Path(template_path) |
| if not p.exists(): |
| raise FileNotFoundError(f"template image not found: {template_path}") |
| if not _is_supported_image(p): |
| raise ValueError(f"unsupported template image format: {template_path}") |
| return str(p) |
| if example_file: |
| p = Path(examples_dir) / example_file |
| if not p.exists(): |
| raise FileNotFoundError(f"example template not found: {p}") |
| if not _is_supported_image(p): |
| raise ValueError(f"unsupported example template format: {p}") |
| return str(p) |
| raise ValueError("Provide either --template or --example-file.") |
|
|
|
|
| def list_template_paths(examples_dir: str, bulk_limit: int = 0) -> list[str]: |
| p = Path(examples_dir) |
| if not p.exists() or not p.is_dir(): |
| raise FileNotFoundError(f"examples dir not found: {examples_dir}") |
| files = sorted( |
| [f for f in p.iterdir() if f.is_file() and _is_supported_image(f)], |
| key=lambda x: x.name.lower(), |
| ) |
| if not files: |
| raise ValueError(f"No supported template images found in: {examples_dir}") |
| if bulk_limit > 0: |
| files = files[:bulk_limit] |
| return [str(f) for f in files] |
|
|
|
|
| def scrape_product_image_url(product_url: str) -> tuple[str, dict]: |
| data = scrape_product(product_url) |
| images = [u.strip() for u in (data.get("product_images") or "").split(",") if u.strip()] |
| first = next((u for u in images if u.startswith("http://") or u.startswith("https://")), "") |
| if not first: |
| raise ValueError("No valid product image URL found after scraping.") |
| return first, data |
|
|
|
|
| def analyse_images(template_path: str, product_image_url: str, logo_path: str | None = None) -> dict: |
| if GEMINI_API_KEY == "YOUR_GEMINI_API_KEY": |
| raise RuntimeError("GEMINI_API_KEY is not set.") |
| client = genai.Client(api_key=GEMINI_API_KEY) |
| template_mime = "image/png" if Path(template_path).suffix.lower() == ".png" else "image/jpeg" |
| parts: list = [ |
| VISION_USER_PROMPT, |
| {"inline_data": {"mime_type": template_mime, "data": load_image_as_b64(template_path)}}, |
| {"file_data": {"mime_type": "image/jpeg", "file_uri": product_image_url}}, |
| ] |
| if logo_path: |
| logo_mime = "image/png" if Path(logo_path).suffix.lower() == ".png" else "image/jpeg" |
| parts.append({"inline_data": {"mime_type": logo_mime, "data": load_image_as_b64(logo_path)}}) |
| response = client.models.generate_content( |
| model=GEMINI_MODEL, |
| contents=parts, |
| config=types.GenerateContentConfig( |
| temperature=0.35, |
| response_mime_type="application/json", |
| thinking_config=types.ThinkingConfig(thinking_level="medium"), |
| ), |
| ) |
| raw = (response.text or "").strip() |
| if raw.startswith("```"): |
| raw = raw.strip().strip("`") |
| if raw.lower().startswith("json"): |
| raw = raw[4:].strip() |
| return json.loads(raw) |
|
|
|
|
| def build_base_prompt_from_analysis(analysis: dict) -> str: |
| direct = (analysis.get("image_generation_prompt") or "").strip() |
| product_lock = ( |
| "CRITICAL PRODUCT LOCK: Keep the exact same product from the reference image. " |
| "Do not change product type, silhouette, geometry, metal tone, gemstone colors, gemstone count, " |
| "stone shapes, setting style, proportions, or signature details. " |
| "No redesign, no substitutions, no style drift." |
| ) |
| logic_lock = ( |
| "LOGICAL REALISM LOCK: Final creative must be physically and contextually believable. " |
| "Jewelry placement must be natural (ring on a finger or realistic display surface), " |
| "hand anatomy must be correct, perspective/scale must be coherent, lighting and shadows must match scene geometry, " |
| "materials must look real (metal reflectance and gemstone refraction), and text placement must feel intentional/readable. " |
| "Avoid impossible poses, floating objects, mismatched reflections, or visually confusing composition." |
| ) |
| if direct: |
| return ( |
| f"{direct} " |
| f"{product_lock} " |
| f"{logic_lock} " |
| "Strictly preserve the template layout and composition one-to-one; " |
| "only replace content (product/copy/logo) inside the same structure." |
| ).strip() |
| synthesized = ( |
| "Create a premium photorealistic jewelry ad. " |
| f"Template guidance: {analysis.get('template_analysis', '')}. " |
| f"Product guidance: {analysis.get('product_description', '')}. " |
| f"Brand guidance: {analysis.get('brand_info', '')}. " |
| "Luxury tone, clear hierarchy, readable text, clean composition." |
| ) |
| neg = (analysis.get("negative_prompt") or "").strip() |
| if neg: |
| synthesized += f" Avoid: {neg}." |
| print(" ⚠️ image_generation_prompt missing; synthesized from analysis.") |
| return ( |
| f"{synthesized} " |
| f"{product_lock} " |
| f"{logic_lock} " |
| "Strictly preserve the template layout and composition one-to-one; " |
| "only replace content (product/copy/logo) inside the same structure." |
| ).strip() |
|
|
|
|
| def generate_with_nano_banana(base_prompt: str, reference_image_urls: list[str], width: int, height: int, num_outputs: int) -> list[str]: |
| os.environ["REPLICATE_API_TOKEN"] = REPLICATE_API_KEY |
| refs = [u for i, u in enumerate(reference_image_urls) if u and u not in reference_image_urls[:i]] |
| urls: list[str] = [] |
| for _ in range(num_outputs): |
| final_url = None |
| final_err = "Image generation failed." |
| for attempt in range(1, GENERATION_MAX_ATTEMPTS + 1): |
| url, err = generate_image_sync( |
| prompt=base_prompt, |
| model_key=MODEL_KEY, |
| width=width, |
| height=height, |
| reference_image_urls=refs, |
| ) |
| if url and not err: |
| final_url = url |
| break |
| final_err = err or "Image generation returned no URL." |
| print(f" ⚠️ Attempt {attempt}/{GENERATION_MAX_ATTEMPTS} failed: {final_err}") |
| if attempt < GENERATION_MAX_ATTEMPTS: |
| time.sleep(GENERATION_RETRY_DELAY_SEC) |
| if not final_url: |
| raise RuntimeError(f"Image generation failed after {GENERATION_MAX_ATTEMPTS} attempts: {final_err}") |
| urls.append(final_url) |
| return urls |
|
|
|
|
| def save_image_from_url(url: str, filename: str) -> Path: |
| resp = requests.get(url, timeout=60) |
| resp.raise_for_status() |
| out = OUTPUT_DIR / filename |
| out.write_bytes(resp.content) |
| return out |
|
|
|
|
| def generate_ad_creative(template_path: str, product_url: str, logo_path: str | None, num_outputs: int, width: int, height: int) -> list[Path]: |
| print("\n" + "═" * 56) |
| print(" AD CREATIVE GENERATOR • Gemini + Nano Banana 2") |
| print("═" * 56) |
| print(f" 🧩 Template image: {template_path}") |
| print(f" 🌍 Reference base URL: {_reference_image_base_url()}") |
|
|
| print("\n[0/3] 🌐 Scraping product page …") |
| product_image_url, product_data = scrape_product_image_url(product_url) |
| print(f" ✅ Product: {product_data.get('product_name', '')}") |
| print(f" ✅ Product image: {product_image_url}") |
|
|
| template_ref = _publish_local_reference(template_path) |
| print(f" ✅ Published template reference: {template_ref}") |
| logo_ref = None |
| if logo_path: |
| logo_ref = _publish_local_reference(logo_path) |
| print(f" ✅ Published logo reference: {logo_ref}") |
|
|
| print(f"\n[1/3] 🔍 Analysing images + building prompt with {GEMINI_MODEL} …") |
| analysis = analyse_images(template_path, product_image_url, logo_path) |
| print(" ✅ Analysis complete.") |
| base_prompt = build_base_prompt_from_analysis(analysis) |
|
|
| ts = int(time.time()) |
| payload = { |
| "analysis": analysis, |
| "meta": { |
| "product_url": product_url, |
| "selected_product_image_url": product_image_url, |
| "template_reference_url": template_ref, |
| "logo_reference_url": logo_ref, |
| "used_template_image": template_path, |
| "product_name": product_data.get("product_name", ""), |
| "model_key": MODEL_KEY, |
| "template_path": template_path, |
| "logo_path": logo_path, |
| "timestamp": ts, |
| }, |
| } |
| analysis_file = OUTPUT_DIR / f"analysis_{ts}.json" |
| analysis_file.write_text(json.dumps(payload, indent=2)) |
| print(" 🧾 Payload:") |
| print(json.dumps(payload, indent=2)) |
| print(f" 📄 Analysis JSON → {analysis_file}") |
|
|
| print("\n[3/3] 🚀 Generating with nano-banana-2 …") |
| refs = [product_image_url, template_ref] |
| if logo_ref: |
| refs.append(logo_ref) |
| print(f" 📦 Generation references ({len(refs)}): {refs}") |
| out_urls = generate_with_nano_banana(base_prompt, refs, width, height, num_outputs) |
|
|
| saved: list[Path] = [] |
| for i, url in enumerate(out_urls, start=1): |
| p = save_image_from_url(url, f"ad_creative_{ts}_{i}.png") |
| saved.append(p) |
| print(f" ✅ Saved → {p}") |
|
|
| print("\n" + "═" * 56) |
| print(f" ✨ {len(saved)} creative(s) ready in ./{OUTPUT_DIR}/") |
| print("═" * 56 + "\n") |
| return saved |
|
|
|
|
| if __name__ == "__main__": |
| import argparse |
|
|
| parser = argparse.ArgumentParser(description="Generate ad creatives — Gemini Vision + Nano Banana 2") |
| parser.add_argument("--template", default=None, help="Direct template path") |
| parser.add_argument("--examples-dir", default="backend/data/creativity_examples", help="Examples dir") |
| parser.add_argument("--example-file", default=None, help="Template filename inside examples dir") |
| parser.add_argument("--product-url", default="https://amalfa.in/products/thalia-prism-ring", help="Product URL") |
| parser.add_argument("--logo", default=None, help="Optional logo path") |
| parser.add_argument("--num", type=int, default=1, help="Number of outputs") |
| parser.add_argument("--width", type=int, default=1024) |
| parser.add_argument("--height", type=int, default=1024) |
| parser.add_argument("--bulk-templates", action="store_true", help="Run generation for all templates in --examples-dir") |
| parser.add_argument("--bulk-limit", type=int, default=0, help="Optional cap on number of templates in bulk mode") |
| args = parser.parse_args() |
|
|
| try: |
| files: list[Path] = [] |
| if args.bulk_templates: |
| templates = list_template_paths(args.examples_dir, args.bulk_limit) |
| print(f"Running bulk mode for {len(templates)} template(s) from {args.examples_dir}") |
| failed: list[str] = [] |
| for idx, template in enumerate(templates, start=1): |
| print(f"\n--- [{idx}/{len(templates)}] Template: {template} ---") |
| try: |
| out = generate_ad_creative( |
| template_path=template, |
| product_url=args.product_url, |
| logo_path=args.logo, |
| num_outputs=args.num, |
| width=args.width, |
| height=args.height, |
| ) |
| files.extend(out) |
| except Exception as ex: |
| print(f" ❌ Template failed: {template} | {ex}") |
| failed.append(template) |
| if failed: |
| print(f"\nBulk completed with {len(failed)} failed template(s).") |
| else: |
| print("\nBulk completed with no template failures.") |
| else: |
| template = resolve_template_path(args.template, args.examples_dir, args.example_file) |
| files = generate_ad_creative( |
| template_path=template, |
| product_url=args.product_url, |
| logo_path=args.logo, |
| num_outputs=args.num, |
| width=args.width, |
| height=args.height, |
| ) |
| print("Output files:") |
| for p in files: |
| print(f" → {p}") |
| except Exception as e: |
| print(f"\n❌ Flow failed: {e}") |
| raise SystemExit(1) |
|
|
|
|