|
|
""" |
|
|
Ad Generator Service |
|
|
|
|
|
Generates high-converting ad creatives using psychological triggers, LLM copy, |
|
|
and image generation. Uses maximum randomization for variety and saves to the |
|
|
Neon database with optional R2 image storage. |
|
|
""" |
|
|
|
|
|
import asyncio |
|
|
import json |
|
|
import os |
|
|
import random |
|
|
import uuid |
|
|
from datetime import datetime |
|
|
from typing import Any, Dict, List, Optional |
|
|
|
|
|
from config import settings |
|
|
from data import auto_insurance, glp1, home_insurance |
|
|
from data.frameworks import ( |
|
|
get_all_frameworks, |
|
|
get_framework, |
|
|
get_framework_visual_guidance, |
|
|
get_frameworks_for_niche, |
|
|
) |
|
|
from data.ecom_verticals import get_random_vertical, get_verticals_for_prompt |
|
|
from data.hooks import get_power_words, get_random_cta as get_hook_cta, get_random_hook_style |
|
|
from data.triggers import get_random_trigger, get_trigger_combination, get_triggers_for_niche |
|
|
from data.visuals import ( |
|
|
get_color_palette, |
|
|
get_random_camera_angle, |
|
|
get_random_composition, |
|
|
get_random_lighting, |
|
|
get_random_mood, |
|
|
get_random_visual_style, |
|
|
) |
|
|
from services.generator_prompts import ( |
|
|
get_headline_formulas, |
|
|
get_numbers_section, |
|
|
get_trending_section, |
|
|
get_trending_image_guidance, |
|
|
) |
|
|
from services.image import image_service |
|
|
from services.llm import llm_service |
|
|
from services.matrix import matrix_service |
|
|
|
|
|
try: |
|
|
from services.database import db_service |
|
|
except ImportError: |
|
|
db_service = None |
|
|
|
|
|
try: |
|
|
from services.r2_storage import get_r2_storage |
|
|
r2_storage_available = True |
|
|
except ImportError: |
|
|
r2_storage_available = False |
|
|
|
|
|
try: |
|
|
from services.third_flow import third_flow_service |
|
|
third_flow_available = True |
|
|
except ImportError: |
|
|
third_flow_available = False |
|
|
|
|
|
try: |
|
|
from services.trend_monitor import trend_monitor |
|
|
trend_monitor_available = True |
|
|
except ImportError: |
|
|
trend_monitor_available = False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
NICHE_DATA = { |
|
|
"home_insurance": home_insurance.get_niche_data, |
|
|
"glp1": glp1.get_niche_data, |
|
|
"auto_insurance": auto_insurance.get_niche_data, |
|
|
} |
|
|
|
|
|
|
|
|
AGE_BRACKETS = [ |
|
|
{"label": "21-40", "color": "yellow/gold button"}, |
|
|
{"label": "41-64", "color": "blue button"}, |
|
|
{"label": "65+", "color": "red button"}, |
|
|
{"label": "50-60 years", "color": "gray box"}, |
|
|
{"label": "60-70 years", "color": "gray box"}, |
|
|
{"label": "70+ years old", "color": "gray box"}, |
|
|
] |
|
|
|
|
|
|
|
|
VINTAGE_FILM_STYLES = [ |
|
|
"grainy 8mm home movie footage from the 1970s, warm faded colors, light leaks", |
|
|
"old VHS tape recording with tracking lines, fuzzy edges, dated look", |
|
|
"vintage 35mm film photograph, visible grain, slightly overexposed highlights", |
|
|
"retro Super 8 film aesthetic, soft focus, amber tint, nostalgic", |
|
|
"aged polaroid photograph style, faded colors, white border, worn edges", |
|
|
"1960s Kodachrome film look, saturated but faded reds and yellows", |
|
|
"old TV broadcast footage, scan lines, slight color bleeding", |
|
|
"vintage sepia-toned photograph, crackled texture, antique feel", |
|
|
"worn 16mm documentary footage, high grain, muted earth tones", |
|
|
"degraded archival footage look, dust particles, scratches, light decay", |
|
|
] |
|
|
|
|
|
|
|
|
FILM_DAMAGE_EFFECTS = [ |
|
|
"film scratches, dust specks, light leaks in corners", |
|
|
"vignette darkening at edges, slight color shift", |
|
|
"horizontal scan lines, minor static noise", |
|
|
"subtle frame jitter effect, worn sprocket marks", |
|
|
"chemical staining, uneven development marks", |
|
|
"faded edges with light burn, aged patina", |
|
|
"dust particles floating, hair gate scratches", |
|
|
"color bleeding at high contrast edges, emulsion damage", |
|
|
] |
|
|
|
|
|
|
|
|
class AdGenerator: |
|
|
""" |
|
|
Generates ad creatives: copy (LLM) + images, with randomization and DB/R2 save. |
|
|
|
|
|
Sections: |
|
|
- Init & config: output dir, local save, niche cache |
|
|
- Niche & strategy: get niche data, compatible strategies, hooks, visuals |
|
|
- CTAs & numbers: generate CTAs, prices, niche numbers |
|
|
- Copy prompt: _build_copy_prompt (angle × concept, frameworks) |
|
|
- Image prompt: _build_image_prompt, _refine_image_prompt |
|
|
- Public: generate_ad, generate_ad_with_matrix, generate_ad_extensive, generate_batch |
|
|
- Matrix: _build_matrix_ad_prompt, _build_matrix_image_prompt |
|
|
- Refine: refine_custom_angle_or_concept |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
def __init__(self): |
|
|
"""Initialize the generator.""" |
|
|
self.output_dir = settings.output_dir |
|
|
os.makedirs(self.output_dir, exist_ok=True) |
|
|
|
|
|
self._niche_data_cache: Dict[str, Dict[str, Any]] = {} |
|
|
|
|
|
def _should_save_locally(self) -> bool: |
|
|
""" |
|
|
Determine if images should be saved locally based on environment settings. |
|
|
|
|
|
Returns: |
|
|
True if images should be saved locally, False otherwise |
|
|
""" |
|
|
|
|
|
if settings.environment.lower() == "production": |
|
|
return settings.save_images_locally |
|
|
|
|
|
return True |
|
|
|
|
|
def _save_image_locally(self, image_bytes: bytes, filename: str) -> Optional[str]: |
|
|
""" |
|
|
Conditionally save image locally based on environment settings. |
|
|
|
|
|
Args: |
|
|
image_bytes: Image data to save |
|
|
filename: Filename for the image |
|
|
|
|
|
Returns: |
|
|
Filepath if saved, None otherwise |
|
|
""" |
|
|
if not self._should_save_locally(): |
|
|
return None |
|
|
|
|
|
try: |
|
|
filepath = os.path.join(self.output_dir, filename) |
|
|
os.makedirs(os.path.dirname(filepath), exist_ok=True) |
|
|
with open(filepath, "wb") as f: |
|
|
f.write(image_bytes) |
|
|
return filepath |
|
|
except Exception as e: |
|
|
print(f"Warning: Failed to save image locally: {e}") |
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_minimal_niche_data_for_custom(self, niche: str) -> Dict[str, Any]: |
|
|
"""Return minimal niche data for custom/others niche (no raise). Used when niche is not in NICHE_DATA.""" |
|
|
label = (niche or "").replace("_", " ").title() or "Custom" |
|
|
return { |
|
|
"niche": niche, |
|
|
"strategies": {}, |
|
|
"strategy_names": [], |
|
|
"creative_directions": ["professional", "clean", "trustworthy"], |
|
|
"visual_moods": ["neutral", "approachable"], |
|
|
"niche_guidance": f"Focus on the {label} niche. Use authentic, low-production visuals.", |
|
|
"image_guidance": f"Images should be appropriate for {label}. Authentic, relatable visuals.", |
|
|
"image_niche_guidance_short": f"NICHE: {label}", |
|
|
"number_config": {}, |
|
|
"price_config": {}, |
|
|
"prompt_sanitization_replacements": [], |
|
|
"visual_library": {}, |
|
|
"image_creative_prompts": [], |
|
|
"all_hooks": [], |
|
|
"all_visual_styles": [], |
|
|
"copy_templates": [], |
|
|
} |
|
|
|
|
|
def _get_niche_data(self, niche: str) -> Dict[str, Any]: |
|
|
"""Load data for a specific niche (cached). For custom/others niche, returns minimal data instead of raising.""" |
|
|
if niche not in self._niche_data_cache: |
|
|
if niche in NICHE_DATA: |
|
|
self._niche_data_cache[niche] = NICHE_DATA[niche]() |
|
|
else: |
|
|
|
|
|
self._niche_data_cache[niche] = self._get_minimal_niche_data_for_custom(niche) |
|
|
return self._niche_data_cache[niche] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_framework_strategy_compatibility(self, framework_key: str, strategy_name: str) -> float: |
|
|
""" |
|
|
Calculate compatibility score between framework and strategy. |
|
|
Returns score 0.0-1.0, higher = better match. |
|
|
""" |
|
|
|
|
|
compatibility_map = { |
|
|
"breaking_news": { |
|
|
"accusation_opener": 0.9, |
|
|
"curiosity_gap": 0.95, |
|
|
"price_focused": 0.8, |
|
|
"proof_based": 0.7, |
|
|
"authority_backed": 0.85, |
|
|
"urgent": 0.95, |
|
|
}, |
|
|
"testimonial": { |
|
|
"proof_based": 0.95, |
|
|
"authority_backed": 0.8, |
|
|
"social_proof": 0.9, |
|
|
}, |
|
|
"before_after": { |
|
|
"proof_based": 0.95, |
|
|
"transformation": 0.9, |
|
|
"price_focused": 0.8, |
|
|
}, |
|
|
"problem_solution": { |
|
|
"accusation_opener": 0.85, |
|
|
"problem_awareness": 0.95, |
|
|
"solution_focused": 0.9, |
|
|
}, |
|
|
"authority": { |
|
|
"authority_backed": 0.95, |
|
|
"expert_recommended": 0.9, |
|
|
"proof_based": 0.8, |
|
|
}, |
|
|
"lifestyle": { |
|
|
"aspirational": 0.9, |
|
|
"identity_targeted": 0.85, |
|
|
"emotional": 0.8, |
|
|
}, |
|
|
"comparison": { |
|
|
"price_focused": 0.9, |
|
|
"proof_based": 0.8, |
|
|
"comparison_logic": 0.95, |
|
|
}, |
|
|
"storytelling": { |
|
|
"emotional": 0.9, |
|
|
"relatable": 0.85, |
|
|
"transformation": 0.8, |
|
|
}, |
|
|
"mobile_post": { |
|
|
"convenience": 0.9, |
|
|
"quick_action": 0.85, |
|
|
"simple": 0.9, |
|
|
}, |
|
|
"educational": { |
|
|
"authority_backed": 0.8, |
|
|
"curiosity_gap": 0.85, |
|
|
"informative": 0.9, |
|
|
}, |
|
|
} |
|
|
|
|
|
|
|
|
strategy_key = strategy_name.lower().replace(" ", "_").replace("-", "_") |
|
|
|
|
|
|
|
|
if framework_key in compatibility_map: |
|
|
if strategy_key in compatibility_map[framework_key]: |
|
|
return compatibility_map[framework_key][strategy_key] |
|
|
|
|
|
|
|
|
return 0.6 |
|
|
|
|
|
def _select_compatible_strategies(self, niche_data: Dict, framework_key: str, count: int = 2) -> List[Dict]: |
|
|
""" |
|
|
Select strategies that are compatible with the chosen framework. |
|
|
Prioritizes high-compatibility matches but ensures variety. |
|
|
""" |
|
|
all_strategies = list(niche_data.get("strategies", {}).values()) |
|
|
|
|
|
if not all_strategies: |
|
|
return [] |
|
|
|
|
|
|
|
|
scored_strategies = [ |
|
|
(strategy, self._get_framework_strategy_compatibility(framework_key, strategy["name"])) |
|
|
for strategy in all_strategies |
|
|
] |
|
|
|
|
|
|
|
|
scored_strategies.sort(key=lambda x: x[1], reverse=True) |
|
|
|
|
|
|
|
|
top_count = max(1, int(count * 0.7)) |
|
|
selected = [] |
|
|
|
|
|
|
|
|
for strategy, score in scored_strategies[:top_count]: |
|
|
selected.append(strategy) |
|
|
|
|
|
|
|
|
remaining = count - len(selected) |
|
|
if remaining > 0: |
|
|
remaining_strategies = [s for s, _ in scored_strategies[top_count:]] |
|
|
if remaining_strategies: |
|
|
selected.extend(random.sample(remaining_strategies, min(remaining, len(remaining_strategies)))) |
|
|
|
|
|
return selected[:count] |
|
|
|
|
|
def _random_strategies(self, niche_data: Dict, count: int = 2) -> List[Dict]: |
|
|
"""Randomly select strategies for maximum variety.""" |
|
|
strategy_names = random.sample(niche_data["strategy_names"], min(count, len(niche_data["strategy_names"]))) |
|
|
return [niche_data["strategies"][name] for name in strategy_names] |
|
|
|
|
|
def _random_hooks(self, strategies: List[Dict], count: int = 3) -> List[str]: |
|
|
"""Randomly select hooks from the chosen strategies (optimized list building).""" |
|
|
|
|
|
all_hooks = [hook for strategy in strategies for hook in strategy["hooks"]] |
|
|
return random.sample(all_hooks, min(count, len(all_hooks))) if all_hooks else [] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_visual_library_for_niche(self, niche: str) -> Dict[str, List[str]]: |
|
|
""" |
|
|
Get visual library categories for a niche. |
|
|
Returns dict mapping category names to visual descriptions. |
|
|
""" |
|
|
niche_data = self._get_niche_data(niche) |
|
|
return niche_data.get("visual_library", {}) |
|
|
|
|
|
def _select_visuals_from_library(self, niche: str, strategy_name: str, count: int = 2) -> List[str]: |
|
|
""" |
|
|
Select visuals from the comprehensive visual library based on strategy. |
|
|
This ensures we use the full visual library, not just strategy-specific visuals. |
|
|
""" |
|
|
visual_library = self._get_visual_library_for_niche(niche) |
|
|
|
|
|
if not visual_library: |
|
|
|
|
|
return [] |
|
|
|
|
|
|
|
|
auto_insurance_strategy_categories = { |
|
|
"official_notification": ["official_notification_style"], |
|
|
"social_post": ["social_post_style"], |
|
|
"coverage_tiers": ["coverage_tiers_style"], |
|
|
"car_brand_grid": ["car_brand_grid_style"], |
|
|
"gift_card_cta": ["gift_card_cta_style"], |
|
|
"savings_urgency": ["savings_urgency_style"], |
|
|
} |
|
|
default_strategy_to_category = { |
|
|
"accusation_opener": ["problem_risk", "disaster_fear"], |
|
|
"curiosity_gap": ["text_first", "minimal_symbolic"], |
|
|
"price_focused": ["comparison_choice", "mortgage_bank"], |
|
|
"proof_based": ["relief", "protection_safety"], |
|
|
"authority_backed": ["mortgage_bank", "protection_safety"], |
|
|
"identity_targeted": ["first_time_homebuyer", "lifestyle"], |
|
|
"family_emotional": ["family_emotional", "protection_safety"], |
|
|
"fear_based": ["disaster_fear", "problem_risk"], |
|
|
"relief": ["relief", "protection_safety"], |
|
|
} |
|
|
strategy_to_category = ( |
|
|
auto_insurance_strategy_categories |
|
|
if niche == "auto_insurance" |
|
|
else default_strategy_to_category |
|
|
) |
|
|
|
|
|
|
|
|
strategy_key = strategy_name.lower().replace(" ", "_").replace("-", "_") |
|
|
|
|
|
|
|
|
requested = strategy_to_category.get(strategy_key, list(visual_library.keys())) |
|
|
categories = [c for c in requested if c in visual_library] or list(visual_library.keys()) |
|
|
|
|
|
|
|
|
selected_visuals = [] |
|
|
for category in categories[:2]: |
|
|
if category in visual_library: |
|
|
visuals = visual_library[category] |
|
|
if visuals: |
|
|
selected_visuals.extend(random.sample(visuals, min(1, len(visuals)))) |
|
|
|
|
|
|
|
|
if len(selected_visuals) < count: |
|
|
all_visuals = [v for visuals in visual_library.values() for v in visuals] |
|
|
remaining = count - len(selected_visuals) |
|
|
if all_visuals: |
|
|
selected_visuals.extend(random.sample(all_visuals, min(remaining, len(all_visuals)))) |
|
|
|
|
|
return selected_visuals[:count] |
|
|
|
|
|
def _random_visual_styles(self, strategies: List[Dict], count: int = 2, niche: str = "", use_library: bool = True) -> List[str]: |
|
|
""" |
|
|
Select visual scene descriptions from strategies and/or visual library. |
|
|
|
|
|
Note: These are SCENE DESCRIPTIONS (what to show), not aesthetic styles. |
|
|
Aesthetic styles come from data/visuals.py VISUAL_STYLES. |
|
|
|
|
|
Args: |
|
|
strategies: List of strategy dicts |
|
|
count: Number of visuals to select |
|
|
niche: Niche name for visual library access |
|
|
use_library: Whether to use comprehensive visual library (improvement) |
|
|
""" |
|
|
|
|
|
|
|
|
all_styles = [style for strategy in strategies for style in strategy.get("visual_styles", [])] |
|
|
|
|
|
|
|
|
if use_library and niche: |
|
|
library_visuals = [ |
|
|
v for strategy in strategies |
|
|
for v in self._select_visuals_from_library(niche, strategy.get("name", ""), count=1) |
|
|
] |
|
|
all_styles.extend(library_visuals) |
|
|
|
|
|
|
|
|
unique_styles = list(dict.fromkeys(all_styles)) |
|
|
return random.sample(unique_styles, min(count, len(unique_styles))) if unique_styles else [] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_niche_specific_guidance(self, niche: str) -> str: |
|
|
"""Get niche-specific guidance for the prompt.""" |
|
|
niche_data = self._get_niche_data(niche) |
|
|
return niche_data.get("niche_guidance", "") |
|
|
|
|
|
async def _generate_ctas_async( |
|
|
self, niche: str, framework_name: Optional[str] = None |
|
|
) -> List[str]: |
|
|
""" |
|
|
Generate 5–8 CTAs for the niche (and optional framework) via LLM. |
|
|
Returns empty list on failure; caller uses single default when empty. |
|
|
""" |
|
|
niche_label = (niche or "").replace("_", " ").title() or "general advertising" |
|
|
context = f"Niche: {niche_label}" |
|
|
if framework_name: |
|
|
context += f". Ad format/framework: {framework_name}" |
|
|
prompt = f"""Generate 5 to 8 short call-to-action (CTA) button/link phrases for a paid ad. |
|
|
|
|
|
{context} |
|
|
|
|
|
Rules: |
|
|
- Generate diverse, scroll-stopping CTAs; niche is optional context; any tone or style allowed. |
|
|
- Return ONLY a JSON object with one key "ctas" containing an array of strings. No other text.""" |
|
|
|
|
|
try: |
|
|
result = await llm_service.generate_json(prompt=prompt, temperature=0.7) |
|
|
ctas = result.get("ctas") if isinstance(result, dict) else None |
|
|
if isinstance(ctas, list) and len(ctas) > 0: |
|
|
return [str(c).strip() for c in ctas if c] |
|
|
except Exception: |
|
|
pass |
|
|
return [] |
|
|
|
|
|
def _generate_specific_price(self, niche: str) -> str: |
|
|
""" |
|
|
Generate price guidance for the AI. |
|
|
The AI will decide whether to include prices and what amounts to use. |
|
|
""" |
|
|
niche_data = self._get_niche_data(niche) |
|
|
default = "Use contextually appropriate prices if the ad format requires them. Make them oddly specific (not rounded) for believability." |
|
|
return niche_data.get("price_config", {}).get("guidance", default) |
|
|
|
|
|
def _generate_niche_numbers(self, niche: str) -> Dict[str, str]: |
|
|
"""Generate niche-specific numbers for authenticity from niche number_config.""" |
|
|
niche_data = self._get_niche_data(niche) |
|
|
config = niche_data.get("number_config", {}) |
|
|
if not config: |
|
|
return {} |
|
|
num_type = config.get("type", "savings") |
|
|
labels = config.get("labels", {}) |
|
|
if num_type == "savings": |
|
|
before_range = config.get("before_range", [1200, 2400]) |
|
|
savings_range = config.get("savings_pct_range", [0.50, 0.75]) |
|
|
before = random.randint(before_range[0], before_range[1]) |
|
|
savings_pct = random.uniform(savings_range[0], savings_range[1]) |
|
|
after = int(before * (1 - savings_pct)) |
|
|
return { |
|
|
"type": "savings", |
|
|
"before": f"${before:,}/year", |
|
|
"after": f"${after}/year", |
|
|
"difference": f"${before - after:,}", |
|
|
"metric": labels.get("metric", "savings per year"), |
|
|
} |
|
|
if num_type == "weight_loss": |
|
|
before_range = config.get("before_range", [180, 280]) |
|
|
loss_range = config.get("loss_range", [25, 65]) |
|
|
days_options = config.get("days_options", [60, 90, 120]) |
|
|
sizes_range = config.get("sizes_range", [2, 5]) |
|
|
before_weight = random.randint(before_range[0], before_range[1]) |
|
|
lbs_lost = random.randint(loss_range[0], loss_range[1]) |
|
|
after_weight = before_weight - lbs_lost |
|
|
days = random.choice(days_options) |
|
|
sizes_dropped = random.randint(sizes_range[0], sizes_range[1]) |
|
|
return { |
|
|
"type": "weight_loss", |
|
|
"before": f"{before_weight} lbs", |
|
|
"after": f"{after_weight} lbs", |
|
|
"difference": f"{lbs_lost} lbs", |
|
|
"days": f"{days} days", |
|
|
"sizes": f"{sizes_dropped} dress sizes", |
|
|
"metric": labels.get("metric", "pounds lost"), |
|
|
} |
|
|
return {} |
|
|
|
|
|
|
|
|
|
|
|
def _build_copy_prompt( |
|
|
self, |
|
|
niche: str, |
|
|
niche_data: Dict, |
|
|
strategies: List[Dict], |
|
|
hooks: List[str], |
|
|
creative_direction: str, |
|
|
framework: str, |
|
|
framework_data: Dict[str, Any], |
|
|
cta: str, |
|
|
trigger_data: Dict[str, Any] = None, |
|
|
trigger_combination: Dict[str, Any] = None, |
|
|
power_words: List[str] = None, |
|
|
angle: Dict[str, Any] = None, |
|
|
concept: Dict[str, Any] = None, |
|
|
target_audience: Optional[str] = None, |
|
|
offer: Optional[str] = None, |
|
|
trending_context: Optional[str] = None, |
|
|
) -> str: |
|
|
""" |
|
|
Build professional LLM prompt for ad copy generation. |
|
|
Uses angle × concept matrix approach for psychological targeting. |
|
|
Can optionally incorporate trending topics for increased relevance. |
|
|
""" |
|
|
strategy_names = [s["name"] for s in strategies] |
|
|
strategy_descriptions = [f"- {s['name']}: {s['description']}" for s in strategies] |
|
|
niche_guidance = self._get_niche_specific_guidance(niche) |
|
|
|
|
|
|
|
|
price_guidance = self._generate_specific_price(niche) |
|
|
niche_numbers = self._generate_niche_numbers(niche) |
|
|
age_bracket = random.choice(AGE_BRACKETS) |
|
|
|
|
|
|
|
|
num_type = niche_data.get("number_config", {}).get("type", "savings") |
|
|
numbers_section = get_numbers_section( |
|
|
niche, num_type, niche_numbers, age_bracket, price_guidance |
|
|
) |
|
|
headline_formulas = get_headline_formulas(niche, num_type) |
|
|
trending_section = get_trending_section(trending_context) |
|
|
|
|
|
prompt = f"""You are an elite direct-response copywriter who has reverse-engineered hundreds of 7-8 figure Facebook ad campaigns. You understand the psychology of scroll-stopping creatives that bypass ad-blindness and trigger immediate emotional response. |
|
|
|
|
|
=== CONTEXT === |
|
|
NICHE: {niche.replace("_", " ").title()} |
|
|
ADVERTISING FRAMEWORK: {framework} |
|
|
FRAMEWORK DESCRIPTION: {framework_data.get('description', '')} |
|
|
FRAMEWORK TONE: {framework_data.get('tone', '')} |
|
|
FRAMEWORK VISUAL STYLE: {framework_data.get('visual_style', '')} |
|
|
FRAMEWORK HEADLINE STYLE: {framework_data.get('headline_style', '') or 'N/A'} |
|
|
CREATIVE DIRECTION: {creative_direction} |
|
|
CALL-TO-ACTION: {cta} |
|
|
{trending_section} |
|
|
|
|
|
=== ANGLE × CONCEPT (inspiration—invent or extend) === |
|
|
ANGLE: {angle.get('name') if angle else 'N/A'} | Trigger: {angle.get('trigger') if angle else 'N/A'} | Example: "{angle.get('example') if angle else 'N/A'}" |
|
|
CONCEPT: {concept.get('name') if concept else 'N/A'} | Structure: {concept.get('structure') if concept else 'N/A'} | Visual: {concept.get('visual') if concept else 'N/A'} |
|
|
|
|
|
Use the above or invent your own triggers/angles/concepts. |
|
|
|
|
|
{f'=== USER INPUTS ===' if target_audience or offer else ''} |
|
|
{f'TARGET AUDIENCE: {target_audience}' if target_audience else ''} |
|
|
{f'OFFER: {offer}' if offer else ''} |
|
|
|
|
|
Target audience can be the given one or any hyperrealistic segment you invent (including outside the niche). You may invent or mix demographics, psychographics, and life stages for maximum diversity and scroll-stopping relevance. |
|
|
|
|
|
For variety, use angles, concepts, and motivators suited to different ecom verticals: fashion, beauty, supplements, fitness, electronics, home goods, pets, food & beverage, jewelry, subscription. This creative can lean into: {get_random_vertical()['name']}. |
|
|
|
|
|
=== FRAMEWORK VISUAL FORMAT (same framework for copy + image) === |
|
|
FRAMEWORK: {framework_data.get('name', framework)} |
|
|
DESCRIPTION: {framework_data.get('description', '')} |
|
|
VISUAL GUIDANCE: {get_framework_visual_guidance(framework_data.get('key', ''))} |
|
|
FONT STYLE: {framework_data.get('font_style', '')} |
|
|
COLORS: {', '.join(f'{k}: {v}' for k, v in framework_data.get('colors', {}).items())} |
|
|
AUTHENTICITY TIPS: {', '.join(framework_data.get('authenticity_tips', [])[:3])} |
|
|
|
|
|
{numbers_section} |
|
|
|
|
|
=== PSYCHOLOGICAL STRATEGIES TO DEPLOY === |
|
|
{chr(10).join(strategy_descriptions)} |
|
|
|
|
|
=== PSYCHOLOGICAL TRIGGERS === |
|
|
PRIMARY TRIGGER: {trigger_data.get('name', 'N/A') if trigger_data else 'N/A'} |
|
|
DESCRIPTION: {trigger_data.get('description', '') if trigger_data else ''} |
|
|
COPY ANGLES: {', '.join(trigger_data.get('copy_angles', [])[:3]) if trigger_data else 'N/A'} |
|
|
|
|
|
TRIGGER COMBINATION: {trigger_combination.get('name', 'N/A') if trigger_combination else 'N/A'} |
|
|
COMBINATION DESCRIPTION: {trigger_combination.get('description', '') if trigger_combination else ''} |
|
|
|
|
|
=== POWER WORDS TO USE === |
|
|
Incorporate these power words naturally: {', '.join(power_words) if power_words else 'N/A'} |
|
|
|
|
|
=== HOOK INSPIRATION (create your own powerful variation) === |
|
|
{chr(10).join(f'- "{hook}"' for hook in hooks)} |
|
|
|
|
|
{niche_guidance} |
|
|
|
|
|
{headline_formulas} |
|
|
Use as inspiration only; invent new scroll-stopping or clickbait headlines as needed. |
|
|
|
|
|
=== YOUR MISSION === |
|
|
Create a scroll-stopping Facebook ad that looks like organic content. Use the angle/concept/trigger above (or invent your own). No restrictions—clickbait, bold statements, and any audience are allowed. |
|
|
|
|
|
=== OUTPUT REQUIREMENTS === |
|
|
|
|
|
1. HEADLINE (The "Arrest") |
|
|
- MAXIMUM 10 words; instant pattern interrupt. Use the angle and trigger above. |
|
|
- Include numbers/prices only when they strengthen the hook; use oddly specific amounts (e.g. $97.33). Include demographic targeting where appropriate. Be SPECIFIC and EMOTIONAL. |
|
|
|
|
|
2. PRIMARY TEXT (The "Agitation") |
|
|
- 2-3 punchy sentences that amplify the emotional hook. Reference the demographic; create urgency; make them FEEL the pain or desire. Oddly specific numbers when they fit. |
|
|
|
|
|
3. DESCRIPTION (The "Payoff") |
|
|
- ONE powerful sentence (max 10 words). Create action urgency. Oddly specific metrics when they enhance the message. |
|
|
|
|
|
4. IMAGE BRIEF (CRITICAL - must match {framework_data.get('name', framework)} framework style) |
|
|
- Follow the concept above: {concept.get('structure') if concept else 'authentic visual'} |
|
|
- Describe the scene for the {framework_data.get('name', framework)} framework ONLY |
|
|
- Visual guidance: {get_framework_visual_guidance(framework_data.get('key', ''))} |
|
|
- The image should look like ORGANIC CONTENT, not an ad |
|
|
- Include: setting, props, mood. People are OPTIONAL—creatives can be product-only, layout-only, document-only, or text-only; only add people when the concept clearly calls for them. |
|
|
- Follow framework authenticity tips: {', '.join(framework_data.get('authenticity_tips', [])[:2])} |
|
|
- CRITICAL: Use ONLY {framework_data.get('name', framework)} format - DO NOT mix with other formats |
|
|
- {f"If chat-style framework (e.g. iMessage, WhatsApp): Include 2-4 readable, coherent messages related to {niche.replace('_', ' ').title()}. Use the headline or a variation as one message." if 'chat_style' in framework_data.get('tags', []) else ""} |
|
|
- {f"If document-style framework (e.g. memo, email): Include readable, properly formatted text related to {niche.replace('_', ' ').title()}." if 'document_style' in framework_data.get('tags', []) else ""} |
|
|
- FOR AUTO INSURANCE: Describe ONLY one of these 6 ad-format layouts: (1) official notification (seal, rate buttons), (2) social post card, (3) coverage tier panels, (4) car brand grid, (5) gift card CTA, (6) savings/urgency (yellow, CONTACT US). No other creative types. Do NOT describe testimonial portraits, couples, speech bubbles, quote bubbles, or people holding documents. Do NOT describe elderly or senior people. Typography, layout, prices, and buttons only. All text in the image must be readable and correctly spelled; no gibberish. |
|
|
- FOR HOME INSURANCE: Document, savings proof, home setting. People optional (e.g. document on table only, or person with document). |
|
|
- FOR GLP-1: REQUIRED - every image brief MUST describe either (1) a GLP-1 medication bottle or pen visible in the scene (e.g. Ozempic, Wegovy, Mounjaro, Zepbound pen or box), OR (2) the text "GLP-1" or a medication name visible on a label, screen, or document. Use VARIETY: product-only (bottle/pen, screen), quiz interfaces, document/surface, or with people when the concept calls for it. People optional. |
|
|
- Visuals and concepts can be invented and need not match niche stereotypes; aim for hyperrealistic, diverse representation (any age, demographic, psychographic). |
|
|
|
|
|
=== PSYCHOLOGICAL PRINCIPLES === |
|
|
- Loss Aversion: Make them feel what they're losing/missing |
|
|
- Specificity = Believability: Specific numbers beat round numbers |
|
|
- Identity Targeting: Direct demographic callouts create self-selection |
|
|
- Curiosity Gap: "THIS" and "Instead" demand click to close loop |
|
|
- Social Proof: "Thousands are doing X" triggers herd behavior |
|
|
- Native Disguise: Content that doesn't look like an ad bypasses filters |
|
|
|
|
|
=== CRITICAL RULES === |
|
|
1. Use the niche as inspiration only—you may invent or blend angles, concepts, triggers, and audiences from any domain for maximum diversity. |
|
|
2. Use SPECIFIC numbers from the numbers section above when they fit; invent or adapt when it strengthens the hook. |
|
|
3. ALWAYS create curiosity gap with "THIS", "Instead", "After", "Secret"; use any clickbait or bold phrasing that works. |
|
|
4. NEVER look like an ad - look like NEWS, PROOF, or UGC |
|
|
5. Use ACCUSATION framing for maximum impact |
|
|
6. The image MUST match the {framework_data.get('name', framework)} framework style |
|
|
|
|
|
=== OUTPUT FORMAT (JSON) === |
|
|
{{ |
|
|
"headline": "<10 words, pattern interrupt>", |
|
|
"primary_text": "<2-3 emotional sentences>", |
|
|
"description": "<one sentence, max 10 words>", |
|
|
"body_story": "<8-12 sentence story: relatable pain, tension, transformation, hope, soft CTA; first/second person>", |
|
|
"image_brief": "<scene following concept and {framework_data.get('name', framework)} style; organic feel>", |
|
|
"cta": "{cta}", |
|
|
"psychological_angle": "<angle name or primary trigger>", |
|
|
"why_it_works": "<brief mechanism>" |
|
|
}} |
|
|
|
|
|
Generate the ad copy now. Organic content, immediate emotional response.""" |
|
|
|
|
|
return prompt |
|
|
|
|
|
|
|
|
|
|
|
def _build_image_prompt( |
|
|
self, |
|
|
niche: str, |
|
|
ad_copy: Dict[str, Any], |
|
|
visual_styles: List[str], |
|
|
visual_mood: str, |
|
|
camera_angle: str, |
|
|
lighting: str, |
|
|
composition: str, |
|
|
visual_style_data: Optional[Dict[str, Any]] = None, |
|
|
trending_context: Optional[str] = None, |
|
|
) -> str: |
|
|
""" |
|
|
Build professional image generation prompt. |
|
|
Uses detailed specifications, style guidance, and negative prompts. |
|
|
Creates AUTHENTIC, ORGANIC CONTENT aesthetic. |
|
|
Text (if included) should be part of the natural scene, NOT an overlay. |
|
|
When trending_context is set, mood and atmosphere align with the current occasion. |
|
|
""" |
|
|
image_brief = ad_copy.get("image_brief", "") |
|
|
headline = ad_copy.get("headline", "") |
|
|
psychological_angle = ad_copy.get("psychological_angle", "") |
|
|
|
|
|
visual_scene_description = image_brief |
|
|
if niche == "glp1": |
|
|
niche_data_img = self._get_niche_data(niche) |
|
|
creative_prompts = niche_data_img.get("image_creative_prompts") or [] |
|
|
if creative_prompts and random.random() < 0.5: |
|
|
chosen = random.choice(creative_prompts) |
|
|
visual_scene_description = chosen.get("image_prompt", image_brief) |
|
|
|
|
|
framework_key = ad_copy.get("framework_key") or ad_copy.get("container_key", "") |
|
|
framework_name = ad_copy.get("framework") or ad_copy.get("container_used", "Standard Ad") |
|
|
price_anchor = ad_copy.get("price_anchor", "$97") |
|
|
|
|
|
framework_data_img = get_framework(framework_key) if framework_key else None |
|
|
if not framework_data_img: |
|
|
framework_data_img = {"name": framework_name, "visual_style": "Standard ad format"} |
|
|
|
|
|
|
|
|
if visual_style_data and isinstance(visual_style_data, dict): |
|
|
visual_style = visual_style_data.get("prompt_guidance", "") |
|
|
visual_style_name = visual_style_data.get("name", "") |
|
|
else: |
|
|
visual_style = random.choice(visual_styles) if visual_styles else "" |
|
|
visual_style_name = "" |
|
|
|
|
|
|
|
|
include_vintage_effects = random.random() < 0.7 |
|
|
include_text_overlay = random.random() < 0.8 |
|
|
include_framework_format = random.random() < 0.4 |
|
|
|
|
|
|
|
|
vintage_style = random.choice(VINTAGE_FILM_STYLES) if include_vintage_effects else None |
|
|
film_damage = random.choice(FILM_DAMAGE_EFFECTS) if include_vintage_effects else None |
|
|
|
|
|
|
|
|
color_palette = get_color_palette(visual_mood.lower().replace(" ", "_").replace("-", "_")) |
|
|
|
|
|
|
|
|
is_auto_insurance_ad_format = niche == "auto_insurance" |
|
|
|
|
|
|
|
|
text_positions = [ |
|
|
"naturally integrated into the scene", |
|
|
"as part of a document or sign in the image", |
|
|
"on a surface within the scene (wall, paper, etc.)", |
|
|
"as natural text element in the environment", |
|
|
"integrated into the scene naturally", |
|
|
] |
|
|
text_position = random.choice(text_positions) |
|
|
|
|
|
text_colors = [ |
|
|
"natural text color that fits the scene", |
|
|
"text that appears naturally in the environment", |
|
|
"text that looks like it belongs in the scene", |
|
|
"authentic text appearance, not overlaid", |
|
|
"text as part of the natural scene elements", |
|
|
"realistic text that fits the environment", |
|
|
] |
|
|
text_color = random.choice(text_colors) |
|
|
|
|
|
|
|
|
if niche == "auto_insurance": |
|
|
niche_data = self._get_niche_data(niche) |
|
|
niche_image_guidance = (niche_data.get("image_guidance", "") + """ |
|
|
|
|
|
PEOPLE, FACES, AND CARS ARE OPTIONAL. Only include them when the VISUAL SCENE description explicitly mentions them. Most ad formats are typography, layout, and buttons only. |
|
|
NO fake or made-up brand/company names (no gibberish); use generic text only or omit. NO in-car dashboard mockups or screens inside car interiors; stick to the 6 defined ad formats only (official notification, social post, coverage tiers, car brand grid, gift card CTA, savings/urgency).""" |
|
|
) |
|
|
elif niche == "glp1": |
|
|
niche_data = self._get_niche_data(niche) |
|
|
niche_image_guidance = (niche_data.get("image_guidance", "") + """ |
|
|
|
|
|
CRITICAL - GLP-1 PRODUCT VISIBILITY: The image MUST show at least one of: (1) A GLP-1 medication bottle or injectable pen (e.g. Ozempic, Wegovy, Mounjaro, Zepbound) in the scene, OR (2) The text "GLP-1" or a medication name (Ozempic, Wegovy, Mounjaro, etc.) visible on a label, screen, document, or surface. Do not generate a GLP-1 ad image without the product or name visible.""" |
|
|
) |
|
|
else: |
|
|
niche_data = self._get_niche_data(niche) |
|
|
niche_image_guidance = niche_data.get("image_guidance", "") |
|
|
|
|
|
|
|
|
framework_visual_guidance = get_framework_visual_guidance(framework_key) if framework_key else (framework_data_img.get("visual_style") or framework_data_img.get("visual_guidance", "")) |
|
|
|
|
|
is_chat_style = "chat_style" in framework_data_img.get("tags", []) |
|
|
is_document_style = "document_style" in framework_data_img.get("tags", []) |
|
|
|
|
|
framework_format_section = f""" |
|
|
=== FRAMEWORK FORMAT REQUIREMENTS === |
|
|
CRITICAL: Use ONLY the {framework_data_img.get('name', 'Standard Ad')} framework style. DO NOT mix multiple formats. |
|
|
|
|
|
Framework: {framework_data_img.get('name', 'Standard Ad')} |
|
|
Visual Guidance: {framework_visual_guidance} |
|
|
|
|
|
REQUIREMENTS: |
|
|
1. USE ONLY THIS FRAMEWORK STYLE - NO mixing (e.g. no WhatsApp + memo, no iMessage + document) |
|
|
2. NO decorative borders, frames, or boxes |
|
|
3. NO banners, badges, or logos |
|
|
4. NO overlay boxes or rectangular overlays |
|
|
5. Focus on authentic, natural appearance of the {framework_data_img.get('name', 'Standard Ad')} format only |
|
|
|
|
|
{"=== TEXT REQUIREMENTS FOR CHAT-STYLE FRAMEWORKS ===" if is_chat_style else ""} |
|
|
{"CRITICAL: All text in chat bubbles MUST be:" if is_chat_style else ""} |
|
|
{"- READABLE and COHERENT (not gibberish, not placeholder text)" if is_chat_style else ""} |
|
|
{f"- Realistic conversation text related to {niche.replace('_', ' ').title()}" if is_chat_style else ""} |
|
|
{"- Proper spelling and grammar" if is_chat_style else ""} |
|
|
{"- Natural message flow (2-4 messages max)" if is_chat_style else ""} |
|
|
{"- Use the headline or a variation as one of the messages" if is_chat_style else ""} |
|
|
{"- NO placeholder text like 'lorem ipsum' or random characters" if is_chat_style else ""} |
|
|
|
|
|
{"=== TEXT REQUIREMENTS FOR DOCUMENT-STYLE FRAMEWORKS ===" if is_document_style else ""} |
|
|
{"CRITICAL: All text in documents MUST be:" if is_document_style else ""} |
|
|
{"- READABLE and COHERENT" if is_document_style else ""} |
|
|
{f"- Related to {niche.replace('_', ' ').title()} topic" if is_document_style else ""} |
|
|
{"- Proper formatting (title, body text, etc.)" if is_document_style else ""} |
|
|
{"- NO gibberish or placeholder text" if is_document_style else ""} |
|
|
""" |
|
|
|
|
|
|
|
|
vintage_section = "" |
|
|
if include_vintage_effects and vintage_style and film_damage: |
|
|
vintage_section = f""" |
|
|
=== VINTAGE FILM AESTHETIC (OPTIONAL - apply if it fits the style) === |
|
|
- {vintage_style} |
|
|
- Film damage: {film_damage} |
|
|
- Warm, faded colors |
|
|
- Visible grain throughout |
|
|
""" |
|
|
|
|
|
framework_section = "" |
|
|
if include_framework_format: |
|
|
|
|
|
framework_section = f""" |
|
|
{framework_format_section} |
|
|
|
|
|
CRITICAL REMINDERS: |
|
|
- Use ONLY {framework_data_img.get('name', 'Standard Ad')} format - NO mixing with other formats |
|
|
- If chat-style framework: All text MUST be readable, coherent, and related to the {niche.replace('_', ' ').title()} niche |
|
|
- If document-style framework: All text MUST be readable and properly formatted |
|
|
- NO gibberish, placeholder text, or random characters |
|
|
- NO decorative borders, frames, or boxes |
|
|
""" |
|
|
else: |
|
|
|
|
|
framework_section = """ |
|
|
=== STYLE GUIDANCE (NO FRAMEWORK UI STYLE) === |
|
|
- Natural, authentic image - no app/screenshot style |
|
|
- Must NOT look like a polished advertisement |
|
|
- Should feel like authentic, organic content |
|
|
- Real, unpolished, natural appearance |
|
|
- NO decorative borders, banners, overlays, or boxes |
|
|
- NO native app interfaces or screenshot-style frames |
|
|
- Just a clean, natural photograph or scene |
|
|
""" |
|
|
|
|
|
text_overlay_section = "" |
|
|
if include_text_overlay: |
|
|
text_overlay_section = f""" |
|
|
=== HEADLINE TEXT (natural text in scene, NOT overlay) === |
|
|
IMPORTANT: Include this text ONLY ONCE in the image, as part of the natural scene. |
|
|
|
|
|
Text to include: "{headline}" |
|
|
|
|
|
CRITICAL TEXT RULES: |
|
|
- Include the text ONLY ONCE - do NOT repeat or duplicate it anywhere |
|
|
- Text should appear in ONE location only (on a document, sign, or surface in the scene) |
|
|
- Position: {text_position} |
|
|
- Style: {text_color} |
|
|
- Ensure readability and correct spelling |
|
|
- Text must be part of the scene, NOT an overlay on top |
|
|
- NO decorative borders, boxes, or frames around text |
|
|
- NO banners, badges, or logos |
|
|
- Do NOT show the same message in multiple formats or locations |
|
|
- If text contains numbers/prices, show them ONCE only |
|
|
""" |
|
|
else: |
|
|
text_overlay_section = """ |
|
|
=== NO TEXT === |
|
|
Do NOT include any text on this image. Focus on the visual scene only. |
|
|
NO text overlays, decorative elements, borders, banners, or overlays. |
|
|
""" |
|
|
|
|
|
if is_auto_insurance_ad_format: |
|
|
people_faces_section = """=== PEOPLE, FACES, CARS: OPTIONAL === |
|
|
Only include people, faces, or vehicles if the VISUAL SCENE description specifically mentions them. Most auto insurance ad formats are typography and layout only - no people or cars needed.""" |
|
|
else: |
|
|
people_faces_section = """=== PEOPLE AND FACES: OPTIONAL === |
|
|
Only include people or faces if the VISUAL SCENE explicitly describes them. Many creatives work without people—product shots, documents, layouts, objects, text-only are all valid. If this image does include people or faces, they MUST look like real, original people with: |
|
|
- Photorealistic faces with natural skin texture, visible pores, and realistic skin imperfections |
|
|
- Natural facial asymmetry (no perfectly symmetrical faces) |
|
|
- Unique, individual facial features (not generic or model-like) |
|
|
- Natural expressions with authentic micro-expressions |
|
|
- Realistic skin tones with natural variations and undertones |
|
|
- Natural hair texture with individual strands |
|
|
- Faces that look like real photographs of real people, NOT AI-generated portraits |
|
|
- Avoid any faces that look synthetic, fake, or obviously computer-generated""" |
|
|
|
|
|
if is_auto_insurance_ad_format: |
|
|
authenticity_section = """=== AUTHENTICITY (only if image includes people/cars per VISUAL SCENE) === |
|
|
If you included people or vehicles, they should look realistic. Otherwise focus on layout and typography only.""" |
|
|
else: |
|
|
authenticity_section = """=== AUTHENTICITY REQUIREMENTS === |
|
|
Creatives may have no people (product-only, document, layout, objects, text). PEOPLE (only if present): |
|
|
- Real, relatable individuals in everyday clothing |
|
|
- Any hyperrealistic audience—any age, demographic, or psychographic; niche is a suggestion, not a rule |
|
|
- Natural expressions and trustworthy energy |
|
|
|
|
|
Visuals and concepts can be invented and need not match niche stereotypes; aim for hyperrealistic, diverse representation. |
|
|
|
|
|
FACES (close-up): |
|
|
- Photorealistic texture with visible pores and natural variation |
|
|
- Subtle asymmetry and unique features—never plastic or model-perfect |
|
|
- Believable micro-expressions with natural lighting and tone shifts |
|
|
|
|
|
SETTINGS (if present): |
|
|
- Real, lived-in locations with everyday props and a touch of natural clutter |
|
|
|
|
|
DOCUMENTS (if present): |
|
|
- Realistic bills or statements with legible details, highlights, and gentle wear""" |
|
|
|
|
|
prompt = f"""Create a Facebook advertisement image that looks like AUTHENTIC, ORGANIC CONTENT. |
|
|
If the image looks like it belongs on a stock website, it has failed. |
|
|
|
|
|
{vintage_section} |
|
|
{framework_section} |
|
|
{text_overlay_section} |
|
|
|
|
|
=== VISUAL SCENE === |
|
|
{visual_scene_description} |
|
|
|
|
|
{"=== AUTO INSURANCE AD GRAPHIC (ONLY these 6 creative types) ===" if is_auto_insurance_ad_format else ""} |
|
|
{"Follow the VISUAL SCENE description exactly. Use ONLY these 6 formats: (1) official notification, (2) social post card, (3) coverage tier panels, (4) car brand grid, (5) gift card CTA, (6) savings/urgency. No other creative types. Include headline, prices, rates, and CTA or button text as specified. Do NOT add people, faces, or cars unless the VISUAL SCENE explicitly asks for them. Do NOT create in-car dashboard mockups, screens inside car interiors, or headshots on displays. Do NOT include fake or made-up brand/company names; use generic text only (e.g. Compare Providers, See Rates) or omit. Render as a clean, modern ad graphic with clear typography and layout." if is_auto_insurance_ad_format else ""} |
|
|
|
|
|
{people_faces_section} |
|
|
|
|
|
{"- For this ad graphic layout, headline and price/rate text are part of the design; include them as specified in VISUAL SCENE." if is_auto_insurance_ad_format else "IMPORTANT: Do NOT display numbers, prices, dollar amounts, or savings figures in the image unless they naturally appear as part of the scene (e.g. on a document or sign). Focus on the visual scene (people optional; product/layout-only is fine). Numbers should be in the ad copy, not the image."} |
|
|
|
|
|
=== VISUAL SPECIFICATIONS === |
|
|
STYLE: {visual_style} - {"clean modern ad graphic, professional layout" if is_auto_insurance_ad_format else "rendered in vintage documentary aesthetic"} |
|
|
MOOD: {visual_mood} - {"trustworthy, clear, high-contrast" if is_auto_insurance_ad_format else "nostalgic, authentic, trustworthy"} |
|
|
CAMERA: {camera_angle} - documentary/candid feel |
|
|
LIGHTING: {lighting} - natural, not studio-polished |
|
|
COMPOSITION: {composition} |
|
|
{get_trending_image_guidance(trending_context)} |
|
|
|
|
|
{niche_image_guidance} |
|
|
{authenticity_section} |
|
|
|
|
|
=== NEGATIVE PROMPTS (AVOID) === |
|
|
- No polished studio lighting or stock-photo aesthetics |
|
|
- No synthetic, plastic, or perfectly symmetrical faces |
|
|
- No decorative frames, overlays, badges, or repeated text |
|
|
- No gibberish, placeholder strings, or fake brand/company names{" (keep labels generic and only where the layout specifies)" if is_auto_insurance_ad_format else ""} |
|
|
- No mixing multiple framework formats or duplicating the same message |
|
|
{"- No in-car dashboard mockups, interior screens, or faces on displays" if is_auto_insurance_ad_format else ""} |
|
|
{"- Numbers, prices, or dollar amounts should appear only when they naturally belong in the scene" if not is_auto_insurance_ad_format else "- Keep typography limited to the headline, rates, and CTA exactly as described"} |
|
|
|
|
|
=== OUTPUT === |
|
|
{"Create a scroll-stopping auto insurance ad graphic. Follow the VISUAL SCENE layout exactly: headline, rates/prices, and CTA or buttons as specified. Use only the 6 defined formats (official notification, social post, coverage tiers, car brand grid, gift card CTA, savings/urgency). No other creative types. No fake brand names; no in-car dashboard or screen mockups; no headshots on displays. Clean typography and layout only." if is_auto_insurance_ad_format else f"Create a scroll-stopping image that feels authentic and organic. {'Include the headline text ONCE as specified above - do NOT duplicate it.' if include_text_overlay else 'Focus on the visual scene without text.'} The image should feel like real content - NOT like a designed advertisement."} |
|
|
|
|
|
CRITICAL REQUIREMENTS: |
|
|
{"- Follow the VISUAL SCENE layout exactly; use borders, buttons, and rate cards only where described." if is_auto_insurance_ad_format else "- Keep the scene natural—no added frames, overlays, or decorative borders"} |
|
|
- Place text or CTA elements exactly once in the location described |
|
|
- Present the core message once; do not repeat it elsewhere |
|
|
{"- Maintain clean typography and composition per VISUAL SCENE." if is_auto_insurance_ad_format else "- Focus on the authentic moment, not a polished ad layout"}""" |
|
|
|
|
|
|
|
|
refined_prompt = self._refine_image_prompt(prompt, niche=niche) |
|
|
return refined_prompt |
|
|
|
|
|
def _refine_image_prompt(self, prompt: str, niche: str = None) -> str: |
|
|
""" |
|
|
Refine and clean the image prompt for affiliate marketing creatives. |
|
|
|
|
|
Fixes illogical elements: |
|
|
- Contradictory instructions |
|
|
- Wrong demographics for niche |
|
|
- Unrealistic visual combinations |
|
|
- Corporate/stock photo aesthetics (bad for affiliate marketing) |
|
|
- Meta-instructions that confuse image models |
|
|
|
|
|
Affiliate marketing principle: Low-production, authentic images outperform polished studio shots. |
|
|
""" |
|
|
import re |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
meta_patterns = [ |
|
|
r'\(for model, not to display\)', |
|
|
r'\(apply these, don\'t display\)', |
|
|
r'\(for reference only\)', |
|
|
r'\(internal use\)', |
|
|
r'IMPORTANT: Display ONLY', |
|
|
r'IMPORTANT: If including', |
|
|
r'IMPORTANT: Use this', |
|
|
r'IMPORTANT: Follow this', |
|
|
r'CRITICAL: Do not', |
|
|
r'NOTE: This is for', |
|
|
] |
|
|
for pattern in meta_patterns: |
|
|
prompt = re.sub(pattern, '', prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
prompt_lower = prompt.lower() |
|
|
has_explicit_audience = any( |
|
|
phrase in prompt_lower |
|
|
for phrase in [ |
|
|
"aged ", "years old", "year old", "woman", "man", "demographic", |
|
|
"hyperrealistic", "diverse representation", "any age", |
|
|
] |
|
|
) |
|
|
if not has_explicit_audience: |
|
|
niche_data_sanitize = self._get_niche_data(niche) if niche else {} |
|
|
for pattern, replacement in niche_data_sanitize.get("prompt_sanitization_replacements", []): |
|
|
prompt = re.sub(pattern, replacement, prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if 'no text' in prompt_lower and ('headline' in prompt_lower or 'text overlay' in prompt_lower): |
|
|
|
|
|
prompt = re.sub( |
|
|
r'===.*HEADLINE.*===[\s\S]*?(?====|$)', |
|
|
'', |
|
|
prompt, |
|
|
flags=re.IGNORECASE | re.MULTILINE |
|
|
) |
|
|
prompt = re.sub(r'headline[:\s]+["\']?[^"\']+["\']?', '', prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
if 'minimalist' in prompt_lower and any(word in prompt_lower for word in ['cluttered', 'busy', 'crowded', 'chaotic']): |
|
|
prompt = re.sub(r'\b(cluttered|busy|crowded|chaotic)\b', 'clean', prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
if 'dark' in prompt_lower and 'moody' in prompt_lower: |
|
|
prompt = re.sub(r'\b(bright|cheerful|sunny|vibrant)\b', 'subtle', prompt, flags=re.IGNORECASE) |
|
|
elif 'bright' in prompt_lower and 'cheerful' in prompt_lower: |
|
|
prompt = re.sub(r'\b(dark|moody|dramatic|gloomy)\b', 'soft', prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
if 'indoor' in prompt_lower or 'inside' in prompt_lower: |
|
|
if 'sunlight streaming' not in prompt_lower: |
|
|
prompt = re.sub(r'\b(outdoor|outside|garden|yard|street)\b(?! view)', 'indoor', prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
stock_photo_terms = [ |
|
|
(r'\bstock photo\b', 'authentic photo'), |
|
|
(r'\bprofessional studio\b', 'natural setting'), |
|
|
(r'\bperfect lighting\b', 'natural lighting'), |
|
|
(r'\bcorporate headshot\b', 'candid portrait'), |
|
|
(r'\bpolished commercial\b', 'authentic UGC-style'), |
|
|
(r'\bgeneric model\b', 'real person'), |
|
|
(r'\bshutterstock style\b', 'authentic casual'), |
|
|
(r'\bistock style\b', 'documentary style'), |
|
|
] |
|
|
for pattern, replacement in stock_photo_terms: |
|
|
prompt = re.sub(pattern, replacement, prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
if 'ugc' in prompt_lower or 'authentic' in prompt_lower: |
|
|
|
|
|
prompt = re.sub(r'\b(studio backdrop|professional lighting setup|commercial shoot)\b', |
|
|
'natural environment', prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
unrealistic_patterns = [ |
|
|
(r'\b(impossibly thin|skeletal|anorexic)\b', 'healthy fit'), |
|
|
] |
|
|
for pattern, replacement in unrealistic_patterns: |
|
|
prompt = re.sub(pattern, replacement, prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lines = prompt.split('\n') |
|
|
cleaned_lines = [] |
|
|
for line in lines: |
|
|
line_lower = line.lower() |
|
|
|
|
|
if any(phrase in line_lower for phrase in [ |
|
|
'do not display', 'not to be displayed', 'for debugging', |
|
|
'metadata:', 'internal:', 'developer note' |
|
|
]): |
|
|
continue |
|
|
|
|
|
if any(phrase in line_lower for phrase in ['n/a', 'not provided', 'see above', 'tbd']): |
|
|
if len(line.strip()) < 20: |
|
|
continue |
|
|
cleaned_lines.append(line) |
|
|
prompt = '\n'.join(cleaned_lines) |
|
|
|
|
|
|
|
|
prompt = re.sub(r'\bCRITICAL:\s*', '', prompt, flags=re.IGNORECASE) |
|
|
prompt = re.sub(r'\bIMPORTANT:\s*', '', prompt, flags=re.IGNORECASE) |
|
|
prompt = re.sub(r'\bNOTE:\s*', '', prompt, flags=re.IGNORECASE) |
|
|
prompt = re.sub(r'\bMUST:\s*', '', prompt, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
prompt = re.sub(r'\n{3,}', '\n\n', prompt) |
|
|
|
|
|
|
|
|
prompt = re.sub(r'===\s*===', '', prompt) |
|
|
prompt = re.sub(r'===\s*\n\s*===', '===', prompt) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
seen_prohibitions = set() |
|
|
final_lines = [] |
|
|
for line in prompt.split('\n'): |
|
|
line_lower = line.lower().strip() |
|
|
if line_lower.startswith('- no ') or line_lower.startswith('no '): |
|
|
prohibition_key = re.sub(r'[^a-z\s]', '', line_lower)[:50] |
|
|
if prohibition_key in seen_prohibitions: |
|
|
continue |
|
|
seen_prohibitions.add(prohibition_key) |
|
|
final_lines.append(line) |
|
|
prompt = '\n'.join(final_lines) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if 'authentic' not in prompt_lower and 'ugc' not in prompt_lower and 'real' not in prompt_lower: |
|
|
prompt += "\n\nStyle: Authentic; can be real-person, product-only, or layout-only. Not overly polished or corporate." |
|
|
|
|
|
|
|
|
prompt = prompt.strip() |
|
|
|
|
|
|
|
|
if prompt and not prompt[-1] in '.!?"\'': |
|
|
prompt += '.' |
|
|
|
|
|
return prompt |
|
|
|
|
|
|
|
|
|
|
|
async def generate_ad( |
|
|
self, |
|
|
niche: str, |
|
|
num_images: int = 1, |
|
|
image_model: Optional[str] = None, |
|
|
username: Optional[str] = None, |
|
|
target_audience: Optional[str] = None, |
|
|
offer: Optional[str] = None, |
|
|
use_trending: bool = False, |
|
|
trending_context: Optional[str] = None, |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Generate a complete ad creative with copy and image. |
|
|
|
|
|
Uses maximum randomization to ensure different results every time: |
|
|
- Random strategy selection (2-3 from pool) |
|
|
- Random hook selection |
|
|
- Random visual style |
|
|
- Random creative direction |
|
|
- Random visual mood |
|
|
- Random camera angle, lighting, composition |
|
|
- Random seed for image generation |
|
|
|
|
|
Can optionally incorporate current trending topics from Google News. |
|
|
|
|
|
Args: |
|
|
niche: Target niche (home_insurance or glp1) |
|
|
num_images: Number of images to generate |
|
|
use_trending: Whether to incorporate current trending topics |
|
|
trending_context: Specific trending context (auto-fetched if not provided) |
|
|
|
|
|
Returns: |
|
|
Dict with ad copy, image path, and metadata |
|
|
""" |
|
|
|
|
|
niche_data = self._get_niche_data(niche) |
|
|
|
|
|
|
|
|
all_frameworks = get_all_frameworks() |
|
|
framework_keys = list(all_frameworks.keys()) |
|
|
|
|
|
|
|
|
if random.random() < 0.7: |
|
|
framework_data = get_frameworks_for_niche(niche, count=1)[0] |
|
|
else: |
|
|
|
|
|
framework_key = random.choice(framework_keys) |
|
|
framework_data = {"key": framework_key, **all_frameworks[framework_key]} |
|
|
|
|
|
framework = framework_data["name"] |
|
|
framework_key = framework_data["key"] |
|
|
|
|
|
|
|
|
num_strategies = random.randint(2, 3) |
|
|
strategies = self._select_compatible_strategies(niche_data, framework_key, count=num_strategies) |
|
|
hooks = self._random_hooks(strategies, count=3) |
|
|
|
|
|
|
|
|
visual_styles = self._random_visual_styles(strategies, count=2, niche=niche, use_library=True) |
|
|
creative_direction = random.choice(niche_data["creative_directions"]) |
|
|
visual_mood = random.choice(niche_data["visual_moods"]) |
|
|
|
|
|
ctas = await self._generate_ctas_async(niche, framework_data.get("name")) |
|
|
cta = random.choice(ctas) if ctas else "Learn More" |
|
|
|
|
|
|
|
|
visual_style_data = get_random_visual_style() |
|
|
camera_angle = get_random_camera_angle() |
|
|
lighting = get_random_lighting() |
|
|
composition = get_random_composition() |
|
|
|
|
|
|
|
|
trigger_data = get_random_trigger() |
|
|
trigger_combination = get_trigger_combination() |
|
|
|
|
|
|
|
|
power_words = get_power_words(category=None, count=5) |
|
|
|
|
|
|
|
|
combination = matrix_service.generate_single_combination(niche) |
|
|
angle = combination["angle"] |
|
|
concept = combination["concept"] |
|
|
|
|
|
|
|
|
trending_info = None |
|
|
if use_trending and trend_monitor_available: |
|
|
try: |
|
|
if not trending_context: |
|
|
|
|
|
print("📰 Fetching current trending topics (occasions + news)...") |
|
|
trends_data = await trend_monitor.get_relevant_trends_for_niche(niche) |
|
|
if trends_data and trends_data.get("relevant_trends"): |
|
|
|
|
|
top_trend = trends_data["relevant_trends"][0] |
|
|
trending_context = f"{top_trend['title']} - {top_trend['summary']}" |
|
|
trending_info = { |
|
|
"title": top_trend["title"], |
|
|
"summary": top_trend["summary"], |
|
|
"category": top_trend.get("category", "General"), |
|
|
"source": "Google News", |
|
|
} |
|
|
print(f"✓ Using trend: {top_trend['title']}") |
|
|
else: |
|
|
|
|
|
trending_info = { |
|
|
"context": trending_context, |
|
|
"source": "User provided", |
|
|
} |
|
|
print(f"✓ Using user-provided trending context") |
|
|
except Exception as e: |
|
|
print(f"Warning: Failed to fetch trending topics: {e}") |
|
|
use_trending = False |
|
|
elif use_trending and not trend_monitor_available: |
|
|
print("Warning: Trending topics requested but trend monitor not available") |
|
|
use_trending = False |
|
|
|
|
|
|
|
|
copy_prompt = self._build_copy_prompt( |
|
|
niche=niche, |
|
|
niche_data=niche_data, |
|
|
strategies=strategies, |
|
|
hooks=hooks, |
|
|
creative_direction=creative_direction, |
|
|
framework=framework, |
|
|
framework_data=framework_data, |
|
|
cta=cta, |
|
|
trigger_data=trigger_data, |
|
|
trigger_combination=trigger_combination, |
|
|
power_words=power_words, |
|
|
angle=angle, |
|
|
concept=concept, |
|
|
target_audience=target_audience, |
|
|
offer=offer, |
|
|
trending_context=trending_context if use_trending else None, |
|
|
) |
|
|
|
|
|
ad_copy = await llm_service.generate_json( |
|
|
prompt=copy_prompt, |
|
|
temperature=0.95, |
|
|
) |
|
|
|
|
|
|
|
|
ad_copy["framework_key"] = framework_data["key"] |
|
|
ad_copy["framework"] = framework_data["name"] |
|
|
ad_copy["container_key"] = framework_data["key"] |
|
|
ad_copy["container_used"] = framework_data["name"] |
|
|
|
|
|
|
|
|
async def generate_single_image(image_index: int): |
|
|
"""Helper function to generate a single image with all processing.""" |
|
|
|
|
|
image_prompt = self._build_image_prompt( |
|
|
niche=niche, |
|
|
ad_copy=ad_copy, |
|
|
visual_styles=visual_styles, |
|
|
visual_mood=visual_mood, |
|
|
camera_angle=camera_angle, |
|
|
lighting=lighting, |
|
|
composition=composition, |
|
|
visual_style_data=visual_style_data, |
|
|
trending_context=trending_context if use_trending else None, |
|
|
) |
|
|
|
|
|
|
|
|
refined_image_prompt = image_prompt |
|
|
|
|
|
|
|
|
seed = random.randint(1, 2147483647) |
|
|
|
|
|
try: |
|
|
|
|
|
image_bytes, model_used, image_url = await image_service.generate( |
|
|
prompt=image_prompt, |
|
|
width=settings.image_width, |
|
|
height=settings.image_height, |
|
|
seed=seed, |
|
|
model_key=image_model, |
|
|
) |
|
|
|
|
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
|
unique_id = str(uuid.uuid4())[:8] |
|
|
filename = f"{niche}_{timestamp}_{unique_id}_{image_index}.png" |
|
|
|
|
|
|
|
|
r2_url = None |
|
|
if r2_storage_available: |
|
|
try: |
|
|
r2_storage = get_r2_storage() |
|
|
if r2_storage: |
|
|
|
|
|
loop = asyncio.get_event_loop() |
|
|
r2_url = await loop.run_in_executor( |
|
|
None, |
|
|
lambda: r2_storage.upload_image( |
|
|
image_bytes=image_bytes, |
|
|
filename=filename, |
|
|
niche=niche, |
|
|
) |
|
|
) |
|
|
print(f"Image {image_index + 1} uploaded to R2: {r2_url}") |
|
|
except Exception as e: |
|
|
print(f"Warning: Failed to upload to R2: {e}. Saving locally as backup.") |
|
|
|
|
|
|
|
|
filepath = self._save_image_locally(image_bytes, filename) |
|
|
|
|
|
|
|
|
final_image_url = r2_url or image_url |
|
|
|
|
|
return { |
|
|
"filename": filename, |
|
|
"filepath": filepath, |
|
|
"image_url": final_image_url, |
|
|
"r2_url": r2_url, |
|
|
"model_used": model_used, |
|
|
"seed": seed, |
|
|
"image_prompt": refined_image_prompt, |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
return { |
|
|
"error": str(e), |
|
|
"seed": seed, |
|
|
"image_prompt": refined_image_prompt, |
|
|
} |
|
|
|
|
|
|
|
|
if num_images > 1: |
|
|
print(f"🔄 Generating {num_images} images in parallel...") |
|
|
image_tasks = [generate_single_image(i) for i in range(num_images)] |
|
|
generated_images = await asyncio.gather(*image_tasks) |
|
|
else: |
|
|
|
|
|
generated_images = [await generate_single_image(0)] |
|
|
|
|
|
|
|
|
ad_id = str(uuid.uuid4()) |
|
|
|
|
|
|
|
|
metadata = { |
|
|
"strategies_used": [s["name"] for s in strategies], |
|
|
"creative_direction": creative_direction, |
|
|
"visual_mood": visual_mood, |
|
|
"framework": framework, |
|
|
"camera_angle": camera_angle, |
|
|
"lighting": lighting, |
|
|
"composition": composition, |
|
|
"hooks_inspiration": hooks, |
|
|
"visual_styles": visual_styles, |
|
|
"visual_style": visual_style_data.get("name") if visual_style_data else None, |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if db_service and db_service.collection is None and settings.mongodb_url: |
|
|
try: |
|
|
await db_service.connect() |
|
|
except Exception as e: |
|
|
print(f"Warning: Could not connect to database: {e}") |
|
|
|
|
|
|
|
|
saved_ad_ids = [] |
|
|
if db_service and db_service.collection is not None and username: |
|
|
for img_idx, image in enumerate(generated_images): |
|
|
if image.get("error"): |
|
|
continue |
|
|
try: |
|
|
db_id = await db_service.save_ad_creative( |
|
|
niche=niche, |
|
|
title=None, |
|
|
headline=ad_copy.get("headline", ""), |
|
|
primary_text=ad_copy.get("primary_text", ""), |
|
|
description=ad_copy.get("description", ""), |
|
|
body_story=ad_copy.get("body_story", ""), |
|
|
cta=ad_copy.get("cta", ""), |
|
|
psychological_angle=ad_copy.get("psychological_angle", ""), |
|
|
why_it_works=ad_copy.get("why_it_works", ""), |
|
|
username=username, |
|
|
image_url=image.get("image_url"), |
|
|
r2_url=image.get("r2_url"), |
|
|
image_filename=image.get("filename"), |
|
|
image_model=image.get("model_used"), |
|
|
image_seed=image.get("seed"), |
|
|
image_prompt=image.get("image_prompt"), |
|
|
generation_method="standard", |
|
|
angle_key=angle.get("key"), |
|
|
angle_name=angle.get("name"), |
|
|
angle_trigger=angle.get("trigger"), |
|
|
angle_category=angle.get("category"), |
|
|
concept_key=concept.get("key"), |
|
|
concept_name=concept.get("name"), |
|
|
concept_structure=concept.get("structure"), |
|
|
concept_visual=concept.get("visual"), |
|
|
concept_category=concept.get("category"), |
|
|
metadata={**metadata, "variation_index": img_idx, "total_variations": len(generated_images)}, |
|
|
) |
|
|
if db_id: |
|
|
saved_ad_ids.append(db_id) |
|
|
print(f"✓ Saved ad creative variation {img_idx + 1}/{len(generated_images)} to database: {db_id}") |
|
|
except Exception as e: |
|
|
print(f"Warning: Failed to save variation {img_idx + 1} to database: {e}") |
|
|
|
|
|
|
|
|
if saved_ad_ids: |
|
|
ad_id = saved_ad_ids[0] |
|
|
|
|
|
|
|
|
result = { |
|
|
"id": ad_id, |
|
|
"niche": niche, |
|
|
"created_at": datetime.now().isoformat(), |
|
|
|
|
|
|
|
|
"headline": ad_copy.get("headline", ""), |
|
|
"primary_text": ad_copy.get("primary_text", ""), |
|
|
"description": ad_copy.get("description", ""), |
|
|
"body_story": ad_copy.get("body_story", ""), |
|
|
"cta": ad_copy.get("cta", ""), |
|
|
"psychological_angle": ad_copy.get("psychological_angle", ""), |
|
|
"why_it_works": ad_copy.get("why_it_works", ""), |
|
|
|
|
|
|
|
|
"images": generated_images, |
|
|
|
|
|
|
|
|
"metadata": metadata, |
|
|
} |
|
|
|
|
|
return result |
|
|
|
|
|
|
|
|
|
|
|
async def generate_ad_with_matrix( |
|
|
self, |
|
|
niche: str, |
|
|
angle_key: Optional[str] = None, |
|
|
concept_key: Optional[str] = None, |
|
|
custom_angle: Optional[str] = None, |
|
|
custom_concept: Optional[str] = None, |
|
|
num_images: int = 1, |
|
|
image_model: Optional[str] = None, |
|
|
username: Optional[str] = None, |
|
|
core_motivator: Optional[str] = None, |
|
|
target_audience: Optional[str] = None, |
|
|
offer: Optional[str] = None, |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Generate ad using angle × concept matrix approach. |
|
|
|
|
|
This provides more systematic ad generation with explicit |
|
|
control over psychological angle and visual concept. |
|
|
|
|
|
Args: |
|
|
niche: Target niche |
|
|
angle_key: Specific angle key (optional, random if not provided) |
|
|
concept_key: Specific concept key (optional, random if not provided) |
|
|
custom_angle: Custom angle dict (used when angle_key is 'custom') |
|
|
custom_concept: Custom concept dict (used when concept_key is 'custom') |
|
|
num_images: Number of images to generate |
|
|
|
|
|
Returns: |
|
|
Complete ad creative with angle and concept metadata |
|
|
""" |
|
|
from data.angles import get_angle_by_key |
|
|
from data.concepts import get_concept_by_key |
|
|
|
|
|
|
|
|
angle = None |
|
|
if angle_key == "custom" and custom_angle: |
|
|
|
|
|
if isinstance(custom_angle, str): |
|
|
|
|
|
try: |
|
|
import json |
|
|
angle = json.loads(custom_angle) |
|
|
except: |
|
|
|
|
|
angle = { |
|
|
"key": "custom", |
|
|
"name": "Custom Angle", |
|
|
"trigger": "Emotion", |
|
|
"example": custom_angle, |
|
|
"category": "Custom", |
|
|
} |
|
|
else: |
|
|
angle = custom_angle |
|
|
|
|
|
angle["key"] = "custom" |
|
|
angle["category"] = angle.get("category", "Custom") |
|
|
elif angle_key: |
|
|
angle = get_angle_by_key(angle_key) |
|
|
if not angle: |
|
|
raise ValueError(f"Invalid angle_key: {angle_key}") |
|
|
|
|
|
|
|
|
concept = None |
|
|
if concept_key == "custom" and custom_concept: |
|
|
|
|
|
if isinstance(custom_concept, str): |
|
|
|
|
|
try: |
|
|
import json |
|
|
concept = json.loads(custom_concept) |
|
|
except: |
|
|
|
|
|
concept = { |
|
|
"key": "custom", |
|
|
"name": "Custom Concept", |
|
|
"structure": custom_concept, |
|
|
"visual": custom_concept, |
|
|
"category": "Custom", |
|
|
} |
|
|
else: |
|
|
concept = custom_concept |
|
|
|
|
|
concept["key"] = "custom" |
|
|
concept["category"] = concept.get("category", "Custom") |
|
|
elif concept_key: |
|
|
concept = get_concept_by_key(concept_key) |
|
|
if not concept: |
|
|
raise ValueError(f"Invalid concept_key: {concept_key}") |
|
|
|
|
|
|
|
|
if angle and concept: |
|
|
combination = { |
|
|
"angle": angle, |
|
|
"concept": concept, |
|
|
"prompt_guidance": f""" |
|
|
ANGLE: {angle.get('name', 'Custom Angle')} |
|
|
- Psychological trigger: {angle.get('trigger', 'Emotion')} |
|
|
- Example: "{angle.get('example', '')}" |
|
|
|
|
|
CONCEPT: {concept.get('name', 'Custom Concept')} |
|
|
- Structure: {concept.get('structure', '')} |
|
|
- Visual: {concept.get('visual', '')} |
|
|
""", |
|
|
} |
|
|
else: |
|
|
|
|
|
combination = matrix_service.generate_single_combination(niche) |
|
|
|
|
|
angle = combination["angle"] |
|
|
concept = combination["concept"] |
|
|
|
|
|
|
|
|
niche_data = NICHE_DATA.get(niche, home_insurance.get_niche_data)() |
|
|
ctas = await self._generate_ctas_async(niche) |
|
|
cta = random.choice(ctas) if ctas else "Learn More" |
|
|
|
|
|
|
|
|
ad_copy_prompt = self._build_matrix_ad_prompt( |
|
|
niche=niche, |
|
|
angle=angle, |
|
|
concept=concept, |
|
|
niche_data=niche_data, |
|
|
cta=cta, |
|
|
core_motivator=core_motivator, |
|
|
target_audience=target_audience, |
|
|
offer=offer, |
|
|
) |
|
|
|
|
|
|
|
|
response_format = { |
|
|
"type": "json_schema", |
|
|
"json_schema": { |
|
|
"name": "ad_copy", |
|
|
"schema": { |
|
|
"type": "object", |
|
|
"properties": { |
|
|
"headline": {"type": "string"}, |
|
|
"primary_text": {"type": "string"}, |
|
|
"description": {"type": "string"}, |
|
|
"body_story": {"type": "string"}, |
|
|
"image_brief": {"type": "string"}, |
|
|
"cta": {"type": "string"}, |
|
|
"psychological_angle": {"type": "string"}, |
|
|
"why_it_works": {"type": "string"}, |
|
|
}, |
|
|
"required": ["headline", "primary_text", "description", "body_story", "image_brief", "cta"], |
|
|
}, |
|
|
}, |
|
|
} |
|
|
|
|
|
ad_copy_response = await llm_service.generate( |
|
|
prompt=ad_copy_prompt, |
|
|
temperature=0.8, |
|
|
response_format=response_format, |
|
|
) |
|
|
|
|
|
import json |
|
|
ad_copy = json.loads(ad_copy_response) |
|
|
|
|
|
|
|
|
image_prompt = self._build_matrix_image_prompt( |
|
|
niche=niche, |
|
|
angle=angle, |
|
|
concept=concept, |
|
|
ad_copy=ad_copy, |
|
|
core_motivator=core_motivator, |
|
|
) |
|
|
|
|
|
|
|
|
refined_image_prompt = image_prompt |
|
|
|
|
|
|
|
|
async def generate_single_matrix_image(image_index: int): |
|
|
"""Helper function to generate a single matrix image with all processing.""" |
|
|
seed = random.randint(1, 2**31 - 1) |
|
|
|
|
|
try: |
|
|
|
|
|
image_bytes, model_used, image_url = await image_service.generate( |
|
|
prompt=image_prompt, |
|
|
model_key=image_model or settings.image_model, |
|
|
width=1024, |
|
|
height=1024, |
|
|
seed=seed, |
|
|
) |
|
|
|
|
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
|
unique_id = uuid.uuid4().hex[:8] |
|
|
filename = f"{niche}_{timestamp}_{unique_id}_{image_index}.png" |
|
|
|
|
|
|
|
|
r2_url = None |
|
|
if r2_storage_available: |
|
|
try: |
|
|
r2_storage = get_r2_storage() |
|
|
if r2_storage: |
|
|
|
|
|
loop = asyncio.get_event_loop() |
|
|
r2_url = await loop.run_in_executor( |
|
|
None, |
|
|
lambda: r2_storage.upload_image( |
|
|
image_bytes=image_bytes, |
|
|
filename=filename, |
|
|
niche=niche, |
|
|
) |
|
|
) |
|
|
print(f"Matrix image {image_index + 1} uploaded to R2: {r2_url}") |
|
|
except Exception as e: |
|
|
print(f"Warning: Failed to upload to R2: {e}. Saving locally as backup.") |
|
|
|
|
|
|
|
|
filepath = self._save_image_locally(image_bytes, filename) |
|
|
|
|
|
|
|
|
final_image_url = r2_url or image_url |
|
|
|
|
|
return { |
|
|
"filename": filename, |
|
|
"filepath": filepath, |
|
|
"image_url": final_image_url, |
|
|
"r2_url": r2_url, |
|
|
"model_used": model_used, |
|
|
"seed": seed, |
|
|
"image_prompt": refined_image_prompt, |
|
|
"error": None, |
|
|
} |
|
|
except Exception as e: |
|
|
return { |
|
|
"filename": None, |
|
|
"filepath": None, |
|
|
"image_url": None, |
|
|
"model_used": settings.image_model, |
|
|
"seed": seed, |
|
|
"image_prompt": refined_image_prompt if 'refined_image_prompt' in locals() else None, |
|
|
"error": str(e), |
|
|
} |
|
|
|
|
|
|
|
|
if num_images > 1: |
|
|
print(f"🔄 Generating {num_images} matrix images in parallel...") |
|
|
image_tasks = [generate_single_matrix_image(i) for i in range(num_images)] |
|
|
images = await asyncio.gather(*image_tasks) |
|
|
else: |
|
|
|
|
|
images = [await generate_single_matrix_image(0)] |
|
|
|
|
|
|
|
|
ad_id = str(uuid.uuid4()) |
|
|
|
|
|
|
|
|
|
|
|
if db_service and db_service.collection is None and settings.mongodb_url: |
|
|
try: |
|
|
await db_service.connect() |
|
|
except Exception as e: |
|
|
print(f"Warning: Could not connect to database: {e}") |
|
|
|
|
|
|
|
|
saved_ad_ids = [] |
|
|
if db_service and db_service.collection is not None and username: |
|
|
for img_idx, image in enumerate(images): |
|
|
if image.get("error"): |
|
|
continue |
|
|
try: |
|
|
db_id = await db_service.save_ad_creative( |
|
|
niche=niche, |
|
|
title=None, |
|
|
headline=ad_copy.get("headline", ""), |
|
|
primary_text=ad_copy.get("primary_text", ""), |
|
|
description=ad_copy.get("description", ""), |
|
|
body_story=ad_copy.get("body_story", ""), |
|
|
cta=ad_copy.get("cta", ""), |
|
|
psychological_angle=ad_copy.get("psychological_angle", angle.get("name", "")), |
|
|
why_it_works=ad_copy.get("why_it_works", ""), |
|
|
username=username, |
|
|
image_url=image.get("image_url"), |
|
|
r2_url=image.get("r2_url"), |
|
|
image_filename=image.get("filename"), |
|
|
image_model=image.get("model_used"), |
|
|
image_seed=image.get("seed"), |
|
|
image_prompt=image.get("image_prompt"), |
|
|
angle_key=angle.get("key"), |
|
|
angle_name=angle.get("name"), |
|
|
angle_trigger=angle.get("trigger"), |
|
|
angle_category=angle.get("category"), |
|
|
concept_key=concept.get("key"), |
|
|
concept_name=concept.get("name"), |
|
|
concept_structure=concept.get("structure"), |
|
|
concept_visual=concept.get("visual"), |
|
|
concept_category=concept.get("category"), |
|
|
generation_method="angle_concept_matrix", |
|
|
metadata={ |
|
|
"generation_method": "angle_concept_matrix", |
|
|
"variation_index": img_idx, |
|
|
"total_variations": len(images), |
|
|
"visual_style": concept.get("name"), |
|
|
}, |
|
|
) |
|
|
if db_id: |
|
|
saved_ad_ids.append(db_id) |
|
|
print(f"✓ Saved matrix ad creative variation {img_idx + 1}/{len(images)} to database: {db_id}") |
|
|
except Exception as e: |
|
|
print(f"Warning: Failed to save matrix variation {img_idx + 1} to database: {e}") |
|
|
|
|
|
|
|
|
if saved_ad_ids: |
|
|
ad_id = saved_ad_ids[0] |
|
|
|
|
|
return { |
|
|
"id": ad_id, |
|
|
"niche": niche, |
|
|
"created_at": datetime.now().isoformat(), |
|
|
"headline": ad_copy.get("headline", ""), |
|
|
"primary_text": ad_copy.get("primary_text", ""), |
|
|
"description": ad_copy.get("description", ""), |
|
|
"body_story": ad_copy.get("body_story", ""), |
|
|
"cta": ad_copy.get("cta", ""), |
|
|
"psychological_angle": ad_copy.get("psychological_angle", angle.get("name", "")), |
|
|
"why_it_works": ad_copy.get("why_it_works", ""), |
|
|
"images": images, |
|
|
"matrix": { |
|
|
"angle": { |
|
|
"key": angle.get("key"), |
|
|
"name": angle.get("name"), |
|
|
"trigger": angle.get("trigger"), |
|
|
"category": angle.get("category"), |
|
|
}, |
|
|
"concept": { |
|
|
"key": concept.get("key"), |
|
|
"name": concept.get("name"), |
|
|
"structure": concept.get("structure"), |
|
|
"visual": concept.get("visual"), |
|
|
"category": concept.get("category"), |
|
|
}, |
|
|
}, |
|
|
"metadata": { |
|
|
"generation_method": "angle_concept_matrix", |
|
|
}, |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async def generate_ad_extensive( |
|
|
self, |
|
|
niche: str, |
|
|
target_audience: Optional[str] = None, |
|
|
offer: Optional[str] = None, |
|
|
num_images: int = 1, |
|
|
image_model: Optional[str] = None, |
|
|
num_strategies: int = 5, |
|
|
username: Optional[str] = None, |
|
|
use_creative_inventor: bool = True, |
|
|
trend_context: Optional[str] = None, |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Generate ad using extensive flow: (inventor or researcher) → creative director → designer → copywriter. |
|
|
When use_creative_inventor=True, the system invents new ad angles, concepts, visuals, and |
|
|
psychological triggers by itself instead of using the fixed researcher step. |
|
|
|
|
|
Args: |
|
|
niche: Target niche (home_insurance, glp1, auto_insurance, or custom) |
|
|
target_audience: Optional target audience description |
|
|
offer: Optional offer to run |
|
|
num_images: Number of images to generate per strategy |
|
|
image_model: Image generation model to use |
|
|
num_strategies: Number of creative strategies to generate |
|
|
use_creative_inventor: If True, use Creative Inventor to generate new angles/concepts/visuals/triggers; if False, use researcher |
|
|
trend_context: Optional trend or occasion context (used when use_creative_inventor=True) |
|
|
|
|
|
Returns: |
|
|
Dict with ad copy, images, and metadata |
|
|
""" |
|
|
if not third_flow_available: |
|
|
raise ValueError("Extensive service not available") |
|
|
|
|
|
|
|
|
niche_map = { |
|
|
"home_insurance": "Home Insurance", |
|
|
"glp1": "GLP-1", |
|
|
"auto_insurance": "Auto Insurance", |
|
|
} |
|
|
niche_display = niche_map.get(niche, niche.replace("_", " ").title()) |
|
|
|
|
|
|
|
|
if not offer: |
|
|
offer = f"Get the best {niche_display} solution" |
|
|
audience_for_retrieve = target_audience or f"People interested in {niche_display}" |
|
|
|
|
|
|
|
|
target_audiences_per_strategy: Optional[List[str]] = None |
|
|
if use_creative_inventor: |
|
|
print("🧠 Step 1: Inventing new ad angles, concepts, visuals, triggers, and hyper-specific audiences...") |
|
|
researcher_output, target_audiences_per_strategy = await asyncio.to_thread( |
|
|
third_flow_service.get_essentials_via_inventor, |
|
|
niche=niche_display, |
|
|
offer=offer, |
|
|
n=num_strategies, |
|
|
target_audience_hint=target_audience if target_audience else None, |
|
|
trend_context=trend_context, |
|
|
) |
|
|
else: |
|
|
print("🔍 Step 1: Researching psychology triggers, angles, and concepts...") |
|
|
if not target_audience: |
|
|
target_audience = audience_for_retrieve |
|
|
researcher_output = await asyncio.to_thread( |
|
|
third_flow_service.researcher, |
|
|
target_audience=target_audience, |
|
|
offer=offer, |
|
|
niche=niche_display |
|
|
) |
|
|
|
|
|
if not researcher_output: |
|
|
raise ValueError("Step 1 returned no results (inventor or researcher)") |
|
|
|
|
|
|
|
|
print("📚 Step 2: Retrieving marketing knowledge...") |
|
|
book_knowledge, ads_knowledge = await asyncio.gather( |
|
|
asyncio.to_thread( |
|
|
third_flow_service.retrieve_search, |
|
|
audience_for_retrieve, offer, niche_display |
|
|
), |
|
|
asyncio.to_thread( |
|
|
third_flow_service.retrieve_ads, |
|
|
audience_for_retrieve, offer, niche_display |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
print(f"🎨 Step 3: Creating {num_strategies} creative strategy/strategies...") |
|
|
print(f"📋 Parameters: num_strategies={num_strategies}, num_images={num_images}") |
|
|
creative_director_target = ( |
|
|
"Various hyper-specific audiences (see per-strategy)" if target_audiences_per_strategy else audience_for_retrieve |
|
|
) |
|
|
creative_strategies = await asyncio.to_thread( |
|
|
third_flow_service.creative_director, |
|
|
researcher_output=researcher_output, |
|
|
book_knowledge=book_knowledge, |
|
|
ads_knowledge=ads_knowledge, |
|
|
target_audience=creative_director_target, |
|
|
offer=offer, |
|
|
niche=niche_display, |
|
|
n=num_strategies, |
|
|
target_audiences=target_audiences_per_strategy, |
|
|
) |
|
|
|
|
|
if not creative_strategies: |
|
|
raise ValueError("Creative director returned no strategies") |
|
|
|
|
|
|
|
|
creative_strategies = creative_strategies[:num_strategies] |
|
|
print(f"📊 Using {len(creative_strategies)} strategy/strategies (requested: {num_strategies})") |
|
|
|
|
|
|
|
|
print(f"⚡ Step 4: Processing {len(creative_strategies)} strategies in parallel...") |
|
|
|
|
|
strategy_tasks = [ |
|
|
asyncio.to_thread( |
|
|
third_flow_service.process_strategy, |
|
|
strategy, |
|
|
niche=niche_display, |
|
|
) |
|
|
for strategy in creative_strategies |
|
|
] |
|
|
strategy_results = await asyncio.gather(*strategy_tasks) |
|
|
|
|
|
|
|
|
|
|
|
strategies_to_process = min(len(strategy_results), num_strategies) |
|
|
print(f"🖼️ Step 5: Generating {num_images} image(s) per strategy for {strategies_to_process} strategy/strategies...") |
|
|
all_results = [] |
|
|
|
|
|
for idx, (prompt, title, body, description) in enumerate(strategy_results[:strategies_to_process]): |
|
|
if idx >= num_strategies: |
|
|
print(f"⚠️ Stopping at strategy {idx + 1} (requested {num_strategies} strategies)") |
|
|
break |
|
|
|
|
|
if not prompt: |
|
|
print(f"Warning: Strategy {idx + 1} has no prompt, skipping...") |
|
|
continue |
|
|
|
|
|
|
|
|
print(f" Generating {num_images} image(s) for strategy {idx + 1}/{len(creative_strategies)}...") |
|
|
|
|
|
|
|
|
variation_modifiers = [ |
|
|
"different camera angle, unique composition", |
|
|
"alternative perspective, varied lighting", |
|
|
"distinct framing, different visual style", |
|
|
"unique viewpoint, varied composition", |
|
|
"alternative angle, different mood", |
|
|
] |
|
|
|
|
|
|
|
|
text_for_image = (title or creative_strategies[idx].titleIdeas or "").strip() if idx < len(creative_strategies) else (title or "").strip() |
|
|
text_instruction = "" |
|
|
if text_for_image: |
|
|
text_instruction = f'\n\n=== TEXT THAT MUST APPEAR IN THE IMAGE ===\nInclude this text visibly and readably in the image (e.g. on a sign, document, phone screen, poster, or surface): "{text_for_image}"\n- Spell it correctly; make it clearly readable.\n- Include it once as part of the natural scene, not as a separate overlay.' |
|
|
|
|
|
async def generate_single_extensive_image(img_idx: int): |
|
|
"""Helper function to generate a single extensive image with all processing.""" |
|
|
try: |
|
|
|
|
|
prompt_with_text_and_camera = f"{prompt}{text_instruction}\n\n=== CAMERA QUALITY ===\n- The image should look like it was shot from a low quality camera\n- Include characteristics of low quality camera: slight grain, reduced sharpness, lower resolution appearance, authentic camera imperfections\n- Should have the authentic feel of a real photo taken with a basic or older camera device" |
|
|
|
|
|
|
|
|
base_refined_prompt = self._refine_image_prompt(prompt_with_text_and_camera, niche=niche) |
|
|
|
|
|
|
|
|
if num_images > 1: |
|
|
variation = variation_modifiers[img_idx % len(variation_modifiers)] |
|
|
refined_prompt = f"{base_refined_prompt}, {variation}" |
|
|
else: |
|
|
refined_prompt = base_refined_prompt |
|
|
|
|
|
|
|
|
actual_seed = random.randint(1, 2147483647) |
|
|
|
|
|
|
|
|
image_bytes, model_used, image_url = await image_service.generate( |
|
|
prompt=refined_prompt, |
|
|
seed=actual_seed, |
|
|
model_key=image_model, |
|
|
) |
|
|
|
|
|
if not image_bytes: |
|
|
print(f"Warning: Failed to generate image {img_idx + 1} for strategy {idx + 1}") |
|
|
return { |
|
|
"error": "Image generation returned no image data", |
|
|
"seed": actual_seed, |
|
|
"image_prompt": refined_prompt, |
|
|
} |
|
|
|
|
|
|
|
|
filename = f"{niche}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}_{img_idx}.png" |
|
|
|
|
|
|
|
|
r2_url = None |
|
|
if r2_storage_available: |
|
|
try: |
|
|
r2_storage = get_r2_storage() |
|
|
if r2_storage: |
|
|
|
|
|
loop = asyncio.get_event_loop() |
|
|
r2_url = await loop.run_in_executor( |
|
|
None, |
|
|
lambda: r2_storage.upload_image( |
|
|
image_bytes, filename=filename, niche=niche |
|
|
) |
|
|
) |
|
|
print(f"Extensive image {img_idx + 1} uploaded to R2: {r2_url}") |
|
|
except Exception as r2_e: |
|
|
print(f"Warning: Failed to upload image to R2: {r2_e}") |
|
|
|
|
|
|
|
|
filepath = self._save_image_locally(image_bytes, filename) |
|
|
|
|
|
|
|
|
final_image_url = r2_url or image_url |
|
|
|
|
|
result = { |
|
|
"filename": filename, |
|
|
"filepath": filepath, |
|
|
"image_url": final_image_url, |
|
|
"r2_url": r2_url, |
|
|
"model_used": model_used, |
|
|
"seed": actual_seed, |
|
|
"image_prompt": refined_prompt, |
|
|
} |
|
|
print(f" ✓ Image {img_idx + 1}/{num_images} generated with seed {actual_seed}") |
|
|
return result |
|
|
except Exception as e: |
|
|
print(f"Error generating image {img_idx + 1} for strategy {idx + 1}: {e}") |
|
|
error_seed = random.randint(1, 2147483647) |
|
|
return { |
|
|
"error": str(e), |
|
|
"seed": error_seed, |
|
|
"image_prompt": refined_prompt if 'refined_prompt' in locals() else None, |
|
|
} |
|
|
|
|
|
|
|
|
if num_images > 1: |
|
|
print(f" 🔄 Generating {num_images} images in parallel for strategy {idx + 1}...") |
|
|
image_tasks = [generate_single_extensive_image(img_idx) for img_idx in range(num_images)] |
|
|
generated_images = await asyncio.gather(*image_tasks) |
|
|
else: |
|
|
|
|
|
generated_images = [await generate_single_extensive_image(0)] |
|
|
|
|
|
if not generated_images: |
|
|
print(f"Warning: No images generated for strategy {idx + 1}, skipping...") |
|
|
continue |
|
|
|
|
|
|
|
|
strategy = creative_strategies[idx] |
|
|
headline = title or strategy.titleIdeas or "Check this out" |
|
|
primary_text = body or strategy.bodyIdeas or "" |
|
|
description_text = description or strategy.captionIdeas or "" |
|
|
cta = strategy.cta or "Learn More" |
|
|
|
|
|
|
|
|
ad_id = str(uuid.uuid4()) |
|
|
saved_ad_ids = [] |
|
|
|
|
|
if db_service and username: |
|
|
for img_idx, image in enumerate(generated_images): |
|
|
if image.get("error"): |
|
|
continue |
|
|
try: |
|
|
db_id = await db_service.save_ad_creative( |
|
|
niche=niche, |
|
|
title=title or "", |
|
|
headline=headline, |
|
|
primary_text=primary_text, |
|
|
description=description_text, |
|
|
body_story=primary_text, |
|
|
cta=cta, |
|
|
psychological_angle=strategy.phsychologyTrigger or "", |
|
|
why_it_works=f"Angle: {strategy.angle}, Concept: {strategy.concept}", |
|
|
username=username, |
|
|
image_url=image.get("image_url"), |
|
|
r2_url=image.get("r2_url"), |
|
|
image_filename=image.get("filename"), |
|
|
image_model=image.get("model_used"), |
|
|
image_prompt=image.get("image_prompt"), |
|
|
generation_method="extensive", |
|
|
metadata={ |
|
|
"generation_method": "extensive", |
|
|
"target_audience": target_audience, |
|
|
"offer": offer, |
|
|
"strategy_index": idx, |
|
|
"psychology_trigger": strategy.phsychologyTrigger, |
|
|
"angle": strategy.angle, |
|
|
"concept": strategy.concept, |
|
|
"visual_direction": strategy.visualDirection, |
|
|
"variation_index": img_idx, |
|
|
"total_variations": len(generated_images), |
|
|
}, |
|
|
) |
|
|
if db_id: |
|
|
saved_ad_ids.append(db_id) |
|
|
print(f"✓ Saved extensive ad creative variation {img_idx + 1}/{len(generated_images)} to database: {db_id}") |
|
|
except Exception as e: |
|
|
print(f"Warning: Failed to save extensive variation {img_idx + 1} to database: {e}") |
|
|
|
|
|
|
|
|
if saved_ad_ids: |
|
|
ad_id = saved_ad_ids[0] |
|
|
|
|
|
all_results.append({ |
|
|
"id": ad_id, |
|
|
"niche": niche, |
|
|
"created_at": datetime.now().isoformat(), |
|
|
"title": title or "", |
|
|
"headline": headline, |
|
|
"primary_text": primary_text, |
|
|
"description": description_text, |
|
|
"body_story": primary_text, |
|
|
"cta": cta, |
|
|
"psychological_angle": strategy.phsychologyTrigger or "", |
|
|
"why_it_works": f"Angle: {strategy.angle}, Concept: {strategy.concept}", |
|
|
"images": generated_images, |
|
|
"metadata": { |
|
|
"strategies_used": [strategy.phsychologyTrigger] if strategy.phsychologyTrigger else ["extensive"], |
|
|
"creative_direction": f"Extensive: {strategy.angle} × {strategy.concept}", |
|
|
"visual_mood": strategy.visualDirection.split(".")[0] if strategy.visualDirection else "authentic", |
|
|
"framework": None, |
|
|
"camera_angle": None, |
|
|
"lighting": None, |
|
|
"composition": None, |
|
|
"hooks_inspiration": [strategy.titleIdeas] if strategy.titleIdeas else [], |
|
|
"visual_styles": [strategy.concept] if strategy.concept else [], |
|
|
}, |
|
|
}) |
|
|
print(f"✓ Strategy {idx + 1} completed with {len(generated_images)} image(s)") |
|
|
|
|
|
|
|
|
if all_results: |
|
|
print(f"📤 Returning {len(all_results)} strategy result(s)") |
|
|
return all_results |
|
|
else: |
|
|
raise ValueError("No ads generated from extensive") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def refine_custom_angle_or_concept( |
|
|
self, |
|
|
text: str, |
|
|
type: str, |
|
|
niche: str, |
|
|
goal: Optional[str] = None, |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Refine a custom angle or concept text using AI. |
|
|
|
|
|
Takes raw user input and structures it properly according to |
|
|
the angle/concept framework used in ad generation. |
|
|
|
|
|
Args: |
|
|
text: Raw user input text |
|
|
type: "angle" or "concept" |
|
|
niche: Target niche for context |
|
|
goal: Optional user goal or context |
|
|
|
|
|
Returns: |
|
|
Structured angle or concept dict |
|
|
""" |
|
|
import json |
|
|
|
|
|
if type == "angle": |
|
|
prompt = f"""You are an expert in direct-response advertising psychology. |
|
|
|
|
|
The user wants to create a custom marketing ANGLE for {niche.replace('_', ' ')} ads. |
|
|
|
|
|
An ANGLE answers "WHY should I care?" - it's the psychological hook that makes someone stop scrolling. |
|
|
|
|
|
User's custom angle idea: |
|
|
"{text}" |
|
|
|
|
|
{f'User goal/context: {goal}' if goal else ''} |
|
|
|
|
|
Example format: Name (2-4 words), Trigger (e.g. Fear, Greed, Relief, FOMO), Example hook (5-10 words). e.g. "Save Money" / Greed / "Save $600/year". |
|
|
|
|
|
Structure the user's idea into a proper angle format. |
|
|
|
|
|
Return JSON: |
|
|
{{ |
|
|
"name": "Short descriptive name (2-4 words)", |
|
|
"trigger": "Primary psychological trigger (e.g., Fear, Hope, Pride, Greed, Relief, FOMO, Curiosity, Anger, Trust)", |
|
|
"example": "A compelling example hook using this angle (5-10 words)" |
|
|
}}""" |
|
|
|
|
|
response_format = { |
|
|
"type": "json_schema", |
|
|
"json_schema": { |
|
|
"name": "refined_angle", |
|
|
"schema": { |
|
|
"type": "object", |
|
|
"properties": { |
|
|
"name": {"type": "string"}, |
|
|
"trigger": {"type": "string"}, |
|
|
"example": {"type": "string"}, |
|
|
}, |
|
|
"required": ["name", "trigger", "example"], |
|
|
}, |
|
|
}, |
|
|
} |
|
|
|
|
|
response = await llm_service.generate( |
|
|
prompt=prompt, |
|
|
temperature=0.7, |
|
|
response_format=response_format, |
|
|
) |
|
|
|
|
|
result = json.loads(response) |
|
|
result["key"] = "custom" |
|
|
result["category"] = "Custom" |
|
|
result["original_text"] = text |
|
|
return result |
|
|
|
|
|
else: |
|
|
prompt = f"""You are an expert in visual advertising and creative direction. |
|
|
|
|
|
The user wants to create a custom visual CONCEPT for {niche.replace('_', ' ')} ads. |
|
|
|
|
|
A CONCEPT answers "HOW do we show it?" - it's the visual approach and structure of the ad. |
|
|
|
|
|
User's custom concept idea: |
|
|
"{text}" |
|
|
|
|
|
{f'User goal/context: {goal}' if goal else ''} |
|
|
|
|
|
Example format: Name (2-4 words), Structure (one sentence), Visual (one sentence with specific details). e.g. "Before/After Split" / "Side-by-side comparison" / "Split screen, clear contrast". |
|
|
|
|
|
Structure the user's idea into a proper concept format. |
|
|
|
|
|
Return JSON: |
|
|
{{ |
|
|
"name": "Short descriptive name (2-4 words)", |
|
|
"structure": "How to structure the visual/ad (one sentence)", |
|
|
"visual": "Visual guidance for the image (one sentence, specific details)" |
|
|
}}""" |
|
|
|
|
|
response_format = { |
|
|
"type": "json_schema", |
|
|
"json_schema": { |
|
|
"name": "refined_concept", |
|
|
"schema": { |
|
|
"type": "object", |
|
|
"properties": { |
|
|
"name": {"type": "string"}, |
|
|
"structure": {"type": "string"}, |
|
|
"visual": {"type": "string"}, |
|
|
}, |
|
|
"required": ["name", "structure", "visual"], |
|
|
}, |
|
|
}, |
|
|
} |
|
|
|
|
|
response = await llm_service.generate( |
|
|
prompt=prompt, |
|
|
temperature=0.7, |
|
|
response_format=response_format, |
|
|
) |
|
|
|
|
|
result = json.loads(response) |
|
|
result["key"] = "custom" |
|
|
result["category"] = "Custom" |
|
|
result["original_text"] = text |
|
|
return result |
|
|
|
|
|
|
|
|
|
|
|
def _build_matrix_ad_prompt( |
|
|
self, |
|
|
niche: str, |
|
|
angle: Dict[str, Any], |
|
|
concept: Dict[str, Any], |
|
|
niche_data: Dict[str, Any], |
|
|
cta: str, |
|
|
core_motivator: Optional[str] = None, |
|
|
target_audience: Optional[str] = None, |
|
|
offer: Optional[str] = None, |
|
|
) -> str: |
|
|
"""Build ad copy prompt using angle + concept framework.""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
numbers = self._generate_niche_numbers(niche) |
|
|
num_type = niche_data.get("number_config", {}).get("type", "savings") |
|
|
if num_type == "weight_loss": |
|
|
numbers_section = f"""SPECIFIC NUMBERS TO USE: |
|
|
- Weight Lost: {numbers['difference']} |
|
|
- Timeframe: {numbers['days']} |
|
|
- Starting: {numbers['before']}, Current: {numbers['after']}""" |
|
|
else: |
|
|
price_guidance = self._generate_specific_price(niche) |
|
|
numbers_section = f"""NUMBERS GUIDANCE (you decide if/when to use): |
|
|
- Price Guidance: {price_guidance} |
|
|
- Saved: {numbers['difference']}/year |
|
|
- Before: {numbers['before']}, After: {numbers['after']} |
|
|
|
|
|
DECISION: Include prices/numbers only if they enhance believability and fit the ad format/strategy. |
|
|
Use oddly specific amounts (e.g., "$97.33" not "$100") when including prices.""" |
|
|
|
|
|
|
|
|
motivator_block = "" |
|
|
if core_motivator: |
|
|
motivator_block = f""" |
|
|
=== CORE EMOTIONAL MOTIVATOR (USE THIS) === |
|
|
The user chose this motivator: "{core_motivator}" |
|
|
|
|
|
Use it to guide the hook, headline, and primary text. The ad must amplify this motivator. |
|
|
""" |
|
|
return f"""You are an elite direct-response copywriter creating a Facebook ad. |
|
|
{motivator_block} |
|
|
=== ANGLE × CONCEPT FRAMEWORK === |
|
|
ANGLE: {angle.get('name')} (Trigger: {angle.get('trigger')}) — Example: "{angle.get('example')}" |
|
|
CONCEPT: {concept.get('name')} — Structure: {concept.get('structure')} | Visual: {concept.get('visual')} |
|
|
|
|
|
For variety, adapt to different ecom verticals (fashion, beauty, supplements, fitness, electronics, home, pets, food). This ad can lean into: {get_random_vertical()['name']}. |
|
|
|
|
|
{f'=== USER INPUTS ===' if target_audience or offer else ''} |
|
|
{f'TARGET AUDIENCE: {target_audience}' if target_audience else ''} |
|
|
{f'OFFER: {offer}' if offer else ''} |
|
|
|
|
|
=== CONTEXT === |
|
|
NICHE: {niche.replace("_", " ").title()} | CTA: {cta} |
|
|
|
|
|
{numbers_section} |
|
|
|
|
|
=== OUTPUT (JSON) === |
|
|
{{ |
|
|
"headline": "<10 words max; use angle/trigger above; add numbers if they strengthen>", |
|
|
"primary_text": "<2-3 emotional sentences>", |
|
|
"description": "<one sentence, 10 words max>", |
|
|
"body_story": "<8-12 sentence story: pain, tension, transformation, hope; first/second person>", |
|
|
"image_brief": "<scene following concept above>", |
|
|
"cta": "{cta}", |
|
|
"psychological_angle": "{angle.get('name')}", |
|
|
"why_it_works": "<brief mechanism>" |
|
|
}} |
|
|
|
|
|
Generate the ad now. Be bold and specific.""" |
|
|
|
|
|
def _build_matrix_image_prompt( |
|
|
self, |
|
|
niche: str, |
|
|
angle: Dict[str, Any], |
|
|
concept: Dict[str, Any], |
|
|
ad_copy: Dict[str, Any], |
|
|
core_motivator: Optional[str] = None, |
|
|
) -> str: |
|
|
"""Build image prompt using concept's visual guidance.""" |
|
|
headline = ad_copy.get("headline", "") |
|
|
|
|
|
text_on_image = (core_motivator or "").strip() or (headline or "") |
|
|
use_motivator = bool((core_motivator or "").strip()) |
|
|
image_brief = ad_copy.get("image_brief", "") |
|
|
|
|
|
|
|
|
natural_blend_options = [ |
|
|
"as a handwritten note or sticky note in the scene", |
|
|
"as text on a phone screen, tablet, or laptop in frame", |
|
|
"as a message in a chat or social feed within the image", |
|
|
"on a sign, whiteboard, or poster that fits the environment", |
|
|
"as a caption or note lying naturally in the scene", |
|
|
"reflected in a mirror or window as part of the environment", |
|
|
"on a document, receipt, or paper in the scene", |
|
|
"as subtle text on a product or package in frame", |
|
|
] |
|
|
text_blend = random.choice(natural_blend_options) |
|
|
|
|
|
|
|
|
text_styles = [ |
|
|
"naturally integrated into the scene", |
|
|
"as part of a document or sign in the image", |
|
|
"on a surface within the scene", |
|
|
"as natural text element in the environment", |
|
|
] |
|
|
text_style = random.choice(text_styles) |
|
|
|
|
|
|
|
|
niche_data_img = self._get_niche_data(niche) |
|
|
niche_guidance = niche_data_img.get("image_niche_guidance_short", "").strip() or f"NICHE: {niche.replace('_', ' ').title()}" |
|
|
|
|
|
if use_motivator: |
|
|
text_section = f'''=== MOTIVATOR PHRASE (blend naturally into the scene) === |
|
|
Phrase: "{text_on_image}" |
|
|
|
|
|
CRITICAL — natural blend only: |
|
|
- Weave this phrase into the scene so it feels organic, not overlaid or forced. |
|
|
- It could appear {text_blend}. |
|
|
- The scene and concept come first; the phrase should feel like a natural part of that world. |
|
|
- No banners, boxes, or decorative overlays. No "ad-like" text placement. |
|
|
- Text must be READABLE and correctly spelled, but never the dominant focal point. |
|
|
- Avoid centering the phrase or making it look pasted on. It should belong in the environment.''' |
|
|
layout_section = f"""=== LAYOUT === |
|
|
- Prioritize the scene from the brief and the {concept.get('name')} concept. |
|
|
- The phrase "{text_on_image}" may appear anywhere it fits naturally (e.g. on a device, note, sign, or surface in frame). |
|
|
- Do NOT force a dedicated "text zone." Let the composition feel organic.""" |
|
|
closing = f'''Create a scroll-stopping, authentic image. The phrase "{text_on_image}" may appear naturally in the scene—never forced or overlay-style.''' |
|
|
else: |
|
|
text_section = f'''=== HEADLINE TEXT (if included, should be part of natural scene) === |
|
|
"{text_on_image}" |
|
|
|
|
|
TEXT REQUIREMENTS (natural integration, NOT overlay): |
|
|
- Text should appear as part of the scene (on documents, signs, surfaces) |
|
|
- Position: {text_style} |
|
|
- Must be READABLE |
|
|
- Spell every word correctly |
|
|
- CRITICAL: NO overlay boxes, banners, or decorative elements |
|
|
- Text should look like it naturally belongs in the scene''' |
|
|
layout_section = f"""=== LAYOUT === |
|
|
- Text zone (bottom 25%): "{text_on_image}" |
|
|
- Visual zone (top 75%): Scene following {concept.get('name')} concept""" |
|
|
closing = f'''Create a scroll-stopping ad image with "{text_on_image}" prominently displayed.''' |
|
|
|
|
|
prompt = f"""Create a Facebook ad image with natural, authentic content. |
|
|
|
|
|
=== CAMERA QUALITY === |
|
|
- The image should look like it was shot from a low quality camera |
|
|
- Include characteristics of low quality camera: slight grain, reduced sharpness, lower resolution appearance, authentic camera imperfections |
|
|
- Should have the authentic feel of a real photo taken with a basic or older camera device |
|
|
|
|
|
{text_section} |
|
|
|
|
|
=== VISUAL CONCEPT: {concept.get('name')} === |
|
|
Structure: {concept.get('structure')} |
|
|
Visual Guidance: {concept.get('visual')} |
|
|
|
|
|
=== SCENE FROM BRIEF === |
|
|
{image_brief} |
|
|
|
|
|
=== PSYCHOLOGICAL ANGLE: {angle.get('name')} === |
|
|
This image should trigger: {angle.get('trigger')} |
|
|
|
|
|
{niche_guidance} |
|
|
|
|
|
=== PEOPLE AND FACES: OPTIONAL === |
|
|
Only include people or faces if the image brief/VISUAL SCENE explicitly describes them. Creatives can be product-only, document-only, or layout-only with no people. If this image does include people or faces, they MUST look like real, original people with: |
|
|
- Photorealistic faces with natural skin texture, visible pores, and realistic skin imperfections |
|
|
- Natural facial asymmetry (no perfectly symmetrical faces) |
|
|
- Unique, individual facial features (not generic or model-like) |
|
|
- Natural expressions with authentic micro-expressions |
|
|
- Realistic skin tones with natural variations and undertones |
|
|
- Natural hair texture with individual strands |
|
|
- Faces that look like real photographs of real people, NOT AI-generated portraits |
|
|
- Avoid any faces that look synthetic, fake, or obviously computer-generated |
|
|
|
|
|
{layout_section} |
|
|
|
|
|
=== AVOID === |
|
|
- Missing or misspelled text |
|
|
- Text that is too small to read |
|
|
- Generic stock photo look |
|
|
- Watermarks, logos |
|
|
- AI-generated faces, synthetic faces, or fake-looking faces |
|
|
- Overly smooth or plastic-looking skin |
|
|
- Perfectly symmetrical faces |
|
|
- Generic or model-like facial features |
|
|
- Uncanny valley faces |
|
|
- Faces that look like they came from a face generator |
|
|
- Overly perfect, flawless skin |
|
|
- Cartoon-like or stylized faces |
|
|
{chr(10) + "- Forced or overlay-style text; the motivator must feel naturally mixed into the scene" if use_motivator else ""} |
|
|
|
|
|
{closing}""" |
|
|
|
|
|
|
|
|
refined_prompt = self._refine_image_prompt(prompt, niche=niche) |
|
|
return refined_prompt |
|
|
|
|
|
|
|
|
|
|
|
async def generate_batch( |
|
|
self, |
|
|
niche: str, |
|
|
count: int = 5, |
|
|
images_per_ad: int = 1, |
|
|
image_model: Optional[str] = None, |
|
|
username: Optional[str] = None, |
|
|
method: Optional[str] = None, |
|
|
target_audience: Optional[str] = None, |
|
|
offer: Optional[str] = None, |
|
|
) -> List[Dict[str, Any]]: |
|
|
""" |
|
|
Generate multiple ad creatives - PARALLELIZED. |
|
|
Uses semaphore to limit concurrent operations and prevent resource exhaustion. |
|
|
|
|
|
Args: |
|
|
niche: Target niche |
|
|
count: Number of ads to generate |
|
|
images_per_ad: Images per ad (typically 1 for batch) |
|
|
image_model: Image model to use |
|
|
username: Username of the user generating the ads |
|
|
method: Generation method - "standard", "matrix", or None (mixed 50/50) |
|
|
|
|
|
Returns: |
|
|
List of ad results (all normalized to GenerateResponse format) |
|
|
""" |
|
|
|
|
|
semaphore = asyncio.Semaphore(3) |
|
|
|
|
|
async def generate_single_ad(ad_index: int): |
|
|
"""Helper function to generate a single ad with semaphore control.""" |
|
|
async with semaphore: |
|
|
try: |
|
|
|
|
|
if method == "matrix": |
|
|
use_matrix = True |
|
|
elif method == "standard": |
|
|
use_matrix = False |
|
|
else: |
|
|
|
|
|
use_matrix = random.random() < 0.5 |
|
|
|
|
|
if use_matrix: |
|
|
|
|
|
result = await self.generate_ad_with_matrix( |
|
|
niche=niche, |
|
|
num_images=images_per_ad, |
|
|
image_model=image_model, |
|
|
username=username, |
|
|
target_audience=target_audience, |
|
|
offer=offer, |
|
|
) |
|
|
|
|
|
|
|
|
matrix_info = result.get("matrix", {}) |
|
|
angle = matrix_info.get("angle", {}) |
|
|
concept = matrix_info.get("concept", {}) |
|
|
|
|
|
|
|
|
result["metadata"] = { |
|
|
"strategies_used": [angle.get("trigger", "emotional_trigger")], |
|
|
"creative_direction": f"Angle: {angle.get('name', '')}, Concept: {concept.get('name', '')}", |
|
|
"visual_mood": concept.get("visual", "").split(".")[0] if concept.get("visual") else "authentic", |
|
|
"framework": None, |
|
|
"camera_angle": None, |
|
|
"lighting": None, |
|
|
"composition": None, |
|
|
"hooks_inspiration": [angle.get("example", "")] if angle.get("example") else [], |
|
|
"visual_styles": [concept.get("structure", "")] if concept.get("structure") else [], |
|
|
} |
|
|
|
|
|
result.pop("matrix", None) |
|
|
else: |
|
|
|
|
|
result = await self.generate_ad( |
|
|
niche=niche, |
|
|
num_images=images_per_ad, |
|
|
image_model=image_model, |
|
|
username=username, |
|
|
target_audience=target_audience, |
|
|
offer=offer, |
|
|
) |
|
|
return result |
|
|
except Exception as e: |
|
|
return { |
|
|
"error": str(e), |
|
|
"index": ad_index, |
|
|
} |
|
|
|
|
|
|
|
|
print(f"🔄 Generating {count} ads in parallel (max 3 concurrent)...") |
|
|
ad_tasks = [generate_single_ad(i) for i in range(count)] |
|
|
results = await asyncio.gather(*ad_tasks) |
|
|
|
|
|
return results |
|
|
|
|
|
|
|
|
|
|
|
ad_generator = AdGenerator() |
|
|
|