|
|
""" |
|
|
Creative Modifier Service |
|
|
Analyzes uploaded creatives and generates modified versions based on user-provided angles/concepts. |
|
|
""" |
|
|
|
|
|
import os |
|
|
import sys |
|
|
import logging |
|
|
import time |
|
|
import uuid |
|
|
from datetime import datetime |
|
|
from typing import Dict, Any, Optional, Tuple, List |
|
|
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
|
|
|
from pydantic import BaseModel |
|
|
from config import settings |
|
|
from services.llm import llm_service |
|
|
from services.image import image_service |
|
|
|
|
|
|
|
|
logging.basicConfig( |
|
|
level=logging.INFO, |
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', |
|
|
datefmt='%Y-%m-%d %H:%M:%S' |
|
|
) |
|
|
logger = logging.getLogger("creative_modifier") |
|
|
|
|
|
|
|
|
try: |
|
|
from services.r2_storage import get_r2_storage |
|
|
r2_storage_available = True |
|
|
except ImportError: |
|
|
r2_storage_available = False |
|
|
|
|
|
|
|
|
class CreativeAnalysis(BaseModel): |
|
|
"""Structured analysis of an uploaded creative.""" |
|
|
visual_style: str |
|
|
color_palette: List[str] |
|
|
mood: str |
|
|
composition: str |
|
|
subject_matter: str |
|
|
text_content: Optional[str] = None |
|
|
current_angle: Optional[str] = None |
|
|
current_concept: Optional[str] = None |
|
|
target_audience: Optional[str] = None |
|
|
strengths: List[str] |
|
|
areas_for_improvement: List[str] |
|
|
|
|
|
|
|
|
class CreativeModifierService: |
|
|
"""Service for analyzing and modifying creative images.""" |
|
|
|
|
|
def __init__(self): |
|
|
"""Initialize the creative modifier service.""" |
|
|
self.output_dir = settings.output_dir |
|
|
os.makedirs(self.output_dir, exist_ok=True) |
|
|
|
|
|
def _should_save_locally(self) -> bool: |
|
|
"""Determine if images should be saved locally based on environment settings.""" |
|
|
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.""" |
|
|
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: |
|
|
logger.warning(f"Failed to save image locally: {e}") |
|
|
return None |
|
|
|
|
|
async def analyze_creative( |
|
|
self, |
|
|
image_bytes: bytes, |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Analyze an uploaded creative image using GPT-4 Vision. |
|
|
|
|
|
Args: |
|
|
image_bytes: Image file bytes to analyze |
|
|
|
|
|
Returns: |
|
|
Analysis results dictionary with structured data |
|
|
""" |
|
|
start_time = time.time() |
|
|
logger.info("=" * 60) |
|
|
logger.info("Starting creative analysis") |
|
|
logger.info(f"Image size: {len(image_bytes)} bytes") |
|
|
|
|
|
system_prompt = """You are an expert creative director and marketing analyst specializing in ad creatives. |
|
|
Your task is to thoroughly analyze advertising images to understand their visual style, messaging strategy, and effectiveness. |
|
|
You must provide detailed, actionable insights that can be used to modify or improve the creative.""" |
|
|
|
|
|
analysis_prompt = """Please analyze this advertising creative in detail. Provide your analysis as a JSON object with the following structure: |
|
|
|
|
|
{ |
|
|
"visual_style": "Description of the visual style (e.g., 'photorealistic', 'illustrated', 'minimalist', 'vibrant', 'muted')", |
|
|
"color_palette": ["List of dominant colors used"], |
|
|
"mood": "The emotional mood conveyed (e.g., 'urgent', 'calm', 'exciting', 'trustworthy')", |
|
|
"composition": "Description of the layout and composition (e.g., 'centered subject', 'rule of thirds', 'text-heavy')", |
|
|
"subject_matter": "What is depicted in the image", |
|
|
"text_content": "Any text visible in the image (or null if none)", |
|
|
"current_angle": "The psychological angle being used (e.g., 'fear of missing out', 'social proof', 'authority')", |
|
|
"current_concept": "The creative concept/format (e.g., 'testimonial', 'before/after', 'lifestyle shot')", |
|
|
"target_audience": "Who this creative seems to target", |
|
|
"strengths": ["List of what works well in this creative"], |
|
|
"areas_for_improvement": ["List of potential improvements"] |
|
|
} |
|
|
|
|
|
Be specific and detailed in your analysis. If you cannot determine something with confidence, make your best assessment based on visual cues.""" |
|
|
|
|
|
try: |
|
|
logger.info("Calling vision API for creative analysis...") |
|
|
analysis_text = await llm_service.analyze_image_with_vision( |
|
|
image_bytes=image_bytes, |
|
|
analysis_prompt=analysis_prompt, |
|
|
system_prompt=system_prompt, |
|
|
) |
|
|
|
|
|
|
|
|
import json |
|
|
analysis_text = analysis_text.strip() |
|
|
if analysis_text.startswith("```json"): |
|
|
analysis_text = analysis_text[7:] |
|
|
if analysis_text.startswith("```"): |
|
|
analysis_text = analysis_text[3:] |
|
|
if analysis_text.endswith("```"): |
|
|
analysis_text = analysis_text[:-3] |
|
|
|
|
|
analysis_data = json.loads(analysis_text.strip()) |
|
|
|
|
|
elapsed_time = time.time() - start_time |
|
|
logger.info(f"β Creative analysis completed successfully in {elapsed_time:.2f}s") |
|
|
|
|
|
|
|
|
suggested_angles = self._generate_suggested_angles(analysis_data) |
|
|
suggested_concepts = self._generate_suggested_concepts(analysis_data) |
|
|
|
|
|
return { |
|
|
"status": "success", |
|
|
"analysis": analysis_data, |
|
|
"suggested_angles": suggested_angles, |
|
|
"suggested_concepts": suggested_concepts, |
|
|
} |
|
|
except json.JSONDecodeError as e: |
|
|
logger.error(f"Failed to parse analysis JSON: {e}") |
|
|
logger.error(f"Raw response: {analysis_text[:500]}...") |
|
|
return { |
|
|
"status": "error", |
|
|
"error": f"Failed to parse analysis response: {str(e)}", |
|
|
} |
|
|
except Exception as e: |
|
|
elapsed_time = time.time() - start_time |
|
|
logger.error(f"β Creative analysis failed after {elapsed_time:.2f}s: {str(e)}") |
|
|
return { |
|
|
"status": "error", |
|
|
"error": str(e), |
|
|
} |
|
|
|
|
|
def _generate_suggested_angles(self, analysis: Dict[str, Any]) -> List[str]: |
|
|
"""Generate suggested angles based on the analysis using all available angles.""" |
|
|
from data.angles import get_all_angles, get_random_angles |
|
|
|
|
|
current_angle = analysis.get("current_angle", "").lower() |
|
|
|
|
|
|
|
|
all_angles = get_all_angles() |
|
|
|
|
|
|
|
|
formatted_angles = [] |
|
|
for angle in all_angles: |
|
|
formatted = f"{angle['name']} ({angle['trigger']})" |
|
|
|
|
|
if current_angle and current_angle in angle['name'].lower(): |
|
|
continue |
|
|
formatted_angles.append(formatted) |
|
|
|
|
|
|
|
|
random_angles = get_random_angles(count=12, diverse=True) |
|
|
suggestions = [] |
|
|
for angle in random_angles: |
|
|
formatted = f"{angle['name']} ({angle['trigger']})" |
|
|
if current_angle and current_angle in angle['name'].lower(): |
|
|
continue |
|
|
suggestions.append(formatted) |
|
|
|
|
|
return suggestions[:8] |
|
|
|
|
|
def _generate_suggested_concepts(self, analysis: Dict[str, Any]) -> List[str]: |
|
|
"""Generate suggested concepts based on the analysis using all available concepts.""" |
|
|
from data.concepts import get_all_concepts, get_random_concepts |
|
|
|
|
|
current_concept = analysis.get("current_concept", "").lower() |
|
|
|
|
|
|
|
|
all_concepts = get_all_concepts() |
|
|
|
|
|
|
|
|
formatted_concepts = [] |
|
|
for concept in all_concepts: |
|
|
formatted = f"{concept['name']} ({concept['structure']})" |
|
|
|
|
|
if current_concept and current_concept in concept['name'].lower(): |
|
|
continue |
|
|
formatted_concepts.append(formatted) |
|
|
|
|
|
|
|
|
random_concepts = get_random_concepts(count=12, diverse=True) |
|
|
suggestions = [] |
|
|
for concept in random_concepts: |
|
|
formatted = f"{concept['name']} ({concept['structure']})" |
|
|
if current_concept and current_concept in concept['name'].lower(): |
|
|
continue |
|
|
suggestions.append(formatted) |
|
|
|
|
|
return suggestions[:8] |
|
|
|
|
|
async def generate_modification_prompt( |
|
|
self, |
|
|
analysis: Dict[str, Any], |
|
|
user_angle: Optional[str] = None, |
|
|
user_concept: Optional[str] = None, |
|
|
mode: str = "modify", |
|
|
user_prompt: Optional[str] = None, |
|
|
) -> str: |
|
|
""" |
|
|
Generate a prompt for modifying the creative based on analysis and user input. |
|
|
|
|
|
Args: |
|
|
analysis: The creative analysis data |
|
|
user_angle: User-provided angle to apply |
|
|
user_concept: User-provided concept to apply |
|
|
mode: "modify" for image-to-image, "inspired" for new generation |
|
|
user_prompt: Custom user instructions for modification |
|
|
|
|
|
Returns: |
|
|
Generated prompt string |
|
|
""" |
|
|
logger.info(f"Generating modification prompt (mode: {mode})") |
|
|
logger.info(f"User angle: {user_angle}") |
|
|
logger.info(f"User concept: {user_concept}") |
|
|
logger.info(f"User prompt: {user_prompt}") |
|
|
|
|
|
|
|
|
if user_prompt and user_prompt.strip(): |
|
|
logger.info("Using custom user prompt directly") |
|
|
return user_prompt.strip() |
|
|
|
|
|
system_prompt = """You are an expert advertising creative director with 20+ years experience. |
|
|
Your task is to create seamless, organic modifications that enhance existing creatives without appearing forced. |
|
|
You understand that effective ads feel authentic and natural, not like concepts were "pasted on." |
|
|
Focus on subtlety, consistency, and maintaining the original's visual language while applying new psychological angles. |
|
|
CRITICAL: ALL generated images MUST be photorealistic. Never mention "AI-generated" or similar terms.""" |
|
|
|
|
|
|
|
|
angle_info = user_angle |
|
|
concept_info = user_concept |
|
|
|
|
|
try: |
|
|
from data.angles import get_all_angles |
|
|
from data.concepts import get_all_concepts |
|
|
|
|
|
|
|
|
if user_angle and "(" not in user_angle: |
|
|
all_angles = get_all_angles() |
|
|
for a in all_angles: |
|
|
if a["name"].lower() == user_angle.lower(): |
|
|
angle_info = f"{a['name']} (Psychological trigger: {a['trigger']})" |
|
|
break |
|
|
|
|
|
|
|
|
if user_concept and "(" not in user_concept: |
|
|
all_concepts = get_all_concepts() |
|
|
for c in all_concepts: |
|
|
if c["name"].lower() == user_concept.lower(): |
|
|
concept_info = f"{c['name']} (Visual structure: {c['structure']})" |
|
|
break |
|
|
except Exception as e: |
|
|
logger.warning(f"Failed to enrich angle/concept data: {e}") |
|
|
|
|
|
|
|
|
original_mood = analysis.get('mood', 'Unknown') |
|
|
original_style = analysis.get('visual_style', 'Unknown') |
|
|
color_palette = analysis.get('color_palette', []) |
|
|
subject_matter = analysis.get('subject_matter', 'Unknown') |
|
|
composition = analysis.get('composition', 'Unknown') |
|
|
|
|
|
|
|
|
if "fan of" in subject_matter.lower() and "bills" in subject_matter.lower(): |
|
|
subject_matter = subject_matter.replace("fan of", "fanned-out stack of").replace("bills", "money/dollar bills") |
|
|
|
|
|
if mode == "modify": |
|
|
|
|
|
prompt_request = f"""ORIGINAL IMAGE CONTEXT: |
|
|
- Subject: {subject_matter} |
|
|
- Mood: {original_mood} |
|
|
- Style: {original_style} |
|
|
- Composition: {composition} |
|
|
- Colors: {', '.join(color_palette[:3]) if color_palette else 'Natural tones'} |
|
|
|
|
|
TRANSFORMATION TASK: |
|
|
- Apply Angle: {angle_info or 'natural enhancement'} |
|
|
- Apply Concept: {concept_info or 'subtle adjustment'} |
|
|
|
|
|
Generate a clear, descriptive transformation prompt (20-40 words) that: |
|
|
1. Specifically describes the VISUAL change needed to reflect the new angle and concept. |
|
|
2. Makes the transformation feel like a professional edit of the original - NOT a different image. |
|
|
3. Keeps the core subjects ({subject_matter}) but adapts their presentation, lighting, or surrounding elements. |
|
|
4. Maintain the {original_style} style and {original_mood} tone. |
|
|
5. Be explicit about what to change (e.g., "Change the lighting to be more dramatic", "Rearrange elements for [concept]") |
|
|
|
|
|
CRITICAL: The instruction must be strong enough for an image-to-image AI to actually make a visible change. |
|
|
|
|
|
Return ONLY the prompt text, no explanations.""" |
|
|
else: |
|
|
|
|
|
prompt_request = f"""ORIGINAL CAMPAIGN INSPIRATION: |
|
|
- Core Vibe: {original_mood} mood, {original_style} aesthetic |
|
|
- Visual Language: {composition}, {subject_matter} |
|
|
|
|
|
NEW AD CREATIVE REQUIREMENTS: |
|
|
- Target Angle: {angle_info or 'natural persuasion'} |
|
|
- Target Concept: {concept_info or 'authentic presentation'} |
|
|
|
|
|
Generate a premium, authentic advertising photography prompt (60-90 words) that: |
|
|
1. Creates a NEW photo that looks like it belongs in the same campaign as the original. |
|
|
2. Centers the visual around {concept_info or 'the concept'}. |
|
|
3. Conveys the {angle_info or 'the angle'} through unposed, authentic human moments and natural lighting. |
|
|
4. Maintains high photorealistic quality with realistic textures and real-world lighting. |
|
|
5. Do NOT describe a generic stock photo; describe a high-end, cinematic brand image. |
|
|
|
|
|
Return ONLY the prompt text, no explanations.""" |
|
|
|
|
|
try: |
|
|
prompt = await llm_service.generate( |
|
|
prompt=prompt_request, |
|
|
system_prompt=system_prompt, |
|
|
temperature=0.7, |
|
|
) |
|
|
|
|
|
|
|
|
prompt = prompt.strip().strip('"').strip("'") |
|
|
|
|
|
|
|
|
seamless_keywords = ["seamless", "organic", "natural", "authentic", "integrated"] |
|
|
has_seamless = any(keyword in prompt.lower() for keyword in seamless_keywords) |
|
|
|
|
|
photorealistic_keywords = [ |
|
|
"photorealistic", "realistic photograph", "cinematic photography", |
|
|
"authentic photo", "natural lighting", "real-world" |
|
|
] |
|
|
has_photorealistic = any(keyword.lower() in prompt.lower() for keyword in photorealistic_keywords) |
|
|
|
|
|
|
|
|
if not has_seamless and mode == "modify": |
|
|
prompt = f"Seamlessly integrated, {prompt}" |
|
|
|
|
|
if not has_photorealistic: |
|
|
|
|
|
prompt = f"Cinematic photography, authentic moment, natural lighting. {prompt}" |
|
|
logger.info("Added natural photography emphasis to prompt") |
|
|
|
|
|
|
|
|
forced_phrases = [ |
|
|
"add", "insert", "place", "include the concept of", |
|
|
"visibly show", "clearly demonstrate", "obviously" |
|
|
] |
|
|
|
|
|
for phrase in forced_phrases: |
|
|
if phrase in prompt.lower(): |
|
|
|
|
|
if phrase == "add": |
|
|
prompt = prompt.lower().replace("add", "naturally incorporate") |
|
|
elif phrase == "insert": |
|
|
prompt = prompt.lower().replace("insert", "subtly integrate") |
|
|
elif phrase == "place": |
|
|
prompt = prompt.lower().replace("place", "position naturally") |
|
|
elif "visibly show" in prompt.lower(): |
|
|
prompt = prompt.lower().replace("visibly show", "suggest through") |
|
|
|
|
|
logger.info(f"Generated natural prompt: {prompt[:100]}...") |
|
|
|
|
|
return prompt |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to generate modification prompt: {e}") |
|
|
|
|
|
if mode == "modify": |
|
|
return f"Cinematic photography, seamless integration. Subtly enhance {subject_matter} to naturally incorporate {user_angle or 'emotional resonance'} through {user_concept or 'visual storytelling'}. Authentic moment, natural lighting, feels like original campaign." |
|
|
else: |
|
|
return f"Cinematic advertising photograph, authentic human moment. {subject_matter} presented with {original_mood} emotional tone, naturally integrating {user_angle or 'persuasive angle'} through {user_concept or 'visual concept'}. Real-world lighting, natural skin textures, campaign-consistent styling." |
|
|
|
|
|
async def modify_creative( |
|
|
self, |
|
|
image_url: str, |
|
|
analysis: Dict[str, Any], |
|
|
user_angle: Optional[str] = None, |
|
|
user_concept: Optional[str] = None, |
|
|
mode: str = "modify", |
|
|
image_model: Optional[str] = None, |
|
|
user_prompt: Optional[str] = None, |
|
|
width: int = 1024, |
|
|
height: int = 1024, |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Modify or generate a new creative based on the original and user input. |
|
|
|
|
|
Args: |
|
|
image_url: URL of the original image |
|
|
analysis: Creative analysis data |
|
|
user_angle: User-provided angle |
|
|
user_concept: User-provided concept |
|
|
mode: "modify" for image-to-image, "inspired" for new generation |
|
|
image_model: Model to use for generation |
|
|
user_prompt: Custom user instructions for modification |
|
|
width: Output width |
|
|
height: Output height |
|
|
|
|
|
Returns: |
|
|
Result dictionary with generated image info |
|
|
""" |
|
|
workflow_start = time.time() |
|
|
logger.info("=" * 80) |
|
|
logger.info("CREATIVE MODIFICATION WORKFLOW STARTED") |
|
|
logger.info(f"Mode: {mode}") |
|
|
logger.info(f"Image URL: {image_url}") |
|
|
logger.info(f"User angle: {user_angle}") |
|
|
logger.info(f"User concept: {user_concept}") |
|
|
logger.info(f"User prompt: {user_prompt}") |
|
|
logger.info(f"Image model: {image_model}") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
result = { |
|
|
"status": "pending", |
|
|
"prompt": None, |
|
|
"image": None, |
|
|
"error": None, |
|
|
} |
|
|
|
|
|
|
|
|
logger.info("STEP 1: Generating modification prompt...") |
|
|
prompt = await self.generate_modification_prompt( |
|
|
analysis=analysis, |
|
|
user_angle=user_angle, |
|
|
user_concept=user_concept, |
|
|
mode=mode, |
|
|
user_prompt=user_prompt, |
|
|
) |
|
|
result["prompt"] = prompt |
|
|
logger.info(f"β Generated prompt: {prompt}") |
|
|
|
|
|
|
|
|
logger.info("STEP 2: Generating modified image...") |
|
|
try: |
|
|
if mode == "modify": |
|
|
|
|
|
|
|
|
|
|
|
generation_params = { |
|
|
"prompt": prompt, |
|
|
"model_key": image_model or "nano-banana", |
|
|
"width": width, |
|
|
"height": height, |
|
|
"image_url": image_url, |
|
|
} |
|
|
else: |
|
|
|
|
|
generation_params = { |
|
|
"prompt": prompt, |
|
|
"model_key": image_model or "nano-banana", |
|
|
"width": width, |
|
|
"height": height, |
|
|
} |
|
|
|
|
|
image_bytes, model_used, generated_url = await image_service.generate(**generation_params) |
|
|
|
|
|
if not image_bytes: |
|
|
raise Exception("Image generation returned no data") |
|
|
|
|
|
logger.info(f"β Image generated successfully ({len(image_bytes)} bytes)") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"β Image generation failed: {e}") |
|
|
result["status"] = "error" |
|
|
result["error"] = f"Image generation failed: {str(e)}" |
|
|
return result |
|
|
|
|
|
|
|
|
logger.info("STEP 3: Saving generated image...") |
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
|
unique_id = uuid.uuid4().hex[:8] |
|
|
filename = f"modified_{timestamp}_{unique_id}.png" |
|
|
|
|
|
|
|
|
r2_url = None |
|
|
if r2_storage_available: |
|
|
try: |
|
|
logger.info("Uploading to R2 storage...") |
|
|
r2_storage = get_r2_storage() |
|
|
if r2_storage: |
|
|
r2_url = r2_storage.upload_image( |
|
|
image_bytes=image_bytes, |
|
|
filename=filename, |
|
|
niche="modified", |
|
|
) |
|
|
logger.info(f"β Uploaded to R2: {r2_url}") |
|
|
except Exception as e: |
|
|
logger.warning(f"R2 upload failed: {e}") |
|
|
|
|
|
|
|
|
filepath = self._save_image_locally(image_bytes, filename) |
|
|
if filepath: |
|
|
logger.info(f"β Saved locally: {filepath}") |
|
|
|
|
|
|
|
|
final_url = r2_url or generated_url |
|
|
|
|
|
result["status"] = "success" |
|
|
result["image"] = { |
|
|
"filename": filename, |
|
|
"filepath": filepath, |
|
|
"image_url": final_url, |
|
|
"r2_url": r2_url, |
|
|
"model_used": model_used, |
|
|
"mode": mode, |
|
|
"applied_angle": user_angle, |
|
|
"applied_concept": user_concept, |
|
|
} |
|
|
|
|
|
total_time = time.time() - workflow_start |
|
|
logger.info("=" * 80) |
|
|
logger.info("β CREATIVE MODIFICATION COMPLETED SUCCESSFULLY") |
|
|
logger.info(f"Total time: {total_time:.2f}s") |
|
|
logger.info(f"Output URL: {final_url}") |
|
|
logger.info("=" * 80) |
|
|
|
|
|
return result |
|
|
|
|
|
|
|
|
|
|
|
creative_modifier_service = CreativeModifierService() |
|
|
|