AI Image Studio, AI podcast Maker, AI product Marketing

This commit is contained in:
ajaysi
2025-11-28 14:33:52 +05:30
parent 77d7c0cde6
commit 49e2131715
122 changed files with 22311 additions and 4331 deletions

View File

@@ -1,8 +1,10 @@
"""API endpoints for Image Studio operations."""
import base64
from pathlib import Path
from typing import Optional, List, Dict, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from services.image_studio import (
@@ -11,10 +13,12 @@ from services.image_studio import (
EditStudioRequest,
ControlStudioRequest,
SocialOptimizerRequest,
TransformImageToVideoRequest,
TalkingAvatarRequest,
)
from services.image_studio.upscale_service import UpscaleStudioRequest
from services.image_studio.templates import Platform, TemplateCategory
from middleware.auth_middleware import get_current_user
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
from utils.logger_utils import get_service_logger
@@ -136,7 +140,12 @@ def get_studio_manager() -> ImageStudioManager:
def _require_user_id(current_user: Dict[str, Any], operation: str) -> str:
"""Ensure user_id is available for protected operations."""
user_id = current_user.get("sub") or current_user.get("user_id")
user_id = (
current_user.get("sub")
or current_user.get("user_id")
or current_user.get("id")
or current_user.get("clerk_user_id")
)
if not user_id:
logger.error(
"[Image Studio] ❌ Missing user_id for %s operation - blocking request",
@@ -762,6 +771,244 @@ async def get_platform_specs(
raise HTTPException(status_code=500, detail=str(e))
# ====================
# TRANSFORM STUDIO ENDPOINTS
# ====================
class TransformImageToVideoRequestModel(BaseModel):
"""Request model for image-to-video transformation."""
image_base64: str = Field(..., description="Image in base64 or data URL format")
prompt: str = Field(..., description="Text prompt describing the video")
audio_base64: Optional[str] = Field(None, description="Optional audio file (wav/mp3, 3-30s, ≤15MB)")
resolution: Literal["480p", "720p", "1080p"] = Field("720p", description="Output resolution")
duration: Literal[5, 10] = Field(5, description="Video duration in seconds")
negative_prompt: Optional[str] = Field(None, description="Negative prompt")
seed: Optional[int] = Field(None, description="Random seed for reproducibility")
enable_prompt_expansion: bool = Field(True, description="Enable prompt optimizer")
class TalkingAvatarRequestModel(BaseModel):
"""Request model for talking avatar generation."""
image_base64: str = Field(..., description="Person image in base64 or data URL")
audio_base64: str = Field(..., description="Audio file in base64 or data URL (wav/mp3, max 10 minutes)")
resolution: Literal["480p", "720p"] = Field("720p", description="Output resolution")
prompt: Optional[str] = Field(None, description="Optional prompt for expression/style")
mask_image_base64: Optional[str] = Field(None, description="Optional mask for animatable regions")
seed: Optional[int] = Field(None, description="Random seed")
class TransformVideoResponse(BaseModel):
"""Response model for video generation."""
success: bool
video_url: Optional[str] = None
video_base64: Optional[str] = None
duration: float
resolution: str
width: int
height: int
file_size: int
cost: float
provider: str
model: str
metadata: Dict[str, Any]
class TransformCostEstimateRequest(BaseModel):
"""Request model for cost estimation."""
operation: Literal["image-to-video", "talking-avatar"] = Field(..., description="Operation type")
resolution: str = Field(..., description="Output resolution")
duration: Optional[int] = Field(None, description="Video duration in seconds (for image-to-video)")
class TransformCostEstimateResponse(BaseModel):
"""Response model for cost estimation."""
estimated_cost: float
breakdown: Dict[str, Any]
currency: str
provider: str
model: str
@router.post("/transform/image-to-video", response_model=TransformVideoResponse, summary="Transform Image to Video")
async def transform_image_to_video(
request: TransformImageToVideoRequestModel,
current_user: Dict[str, Any] = Depends(get_current_user),
studio_manager: ImageStudioManager = Depends(get_studio_manager),
):
"""Transform an image into a video using WAN 2.5.
This endpoint generates a video from an image and text prompt, with optional audio synchronization.
Supports resolutions of 480p, 720p, and 1080p, with durations of 5 or 10 seconds.
Returns:
Video generation result with URL and metadata
"""
try:
user_id = _require_user_id(current_user, "image-to-video transformation")
logger.info(f"[Transform Studio] Image-to-video request from user {user_id}: resolution={request.resolution}, duration={request.duration}s")
# Convert request to service request
transform_request = TransformImageToVideoRequest(
image_base64=request.image_base64,
prompt=request.prompt,
audio_base64=request.audio_base64,
resolution=request.resolution,
duration=request.duration,
negative_prompt=request.negative_prompt,
seed=request.seed,
enable_prompt_expansion=request.enable_prompt_expansion,
)
# Generate video
result = await studio_manager.transform_image_to_video(transform_request, user_id=user_id)
logger.info(f"[Transform Studio] ✅ Image-to-video completed: cost=${result['cost']:.2f}")
return TransformVideoResponse(**result)
except ValueError as e:
logger.error(f"[Transform Studio] ❌ Validation error: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"[Transform Studio] ❌ Unexpected error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Video generation failed: {str(e)}")
@router.post("/transform/talking-avatar", response_model=TransformVideoResponse, summary="Create Talking Avatar")
async def create_talking_avatar(
request: TalkingAvatarRequestModel,
current_user: Dict[str, Any] = Depends(get_current_user),
studio_manager: ImageStudioManager = Depends(get_studio_manager),
):
"""Create a talking avatar video using InfiniteTalk.
This endpoint generates a video with precise lip-sync from an image and audio file.
Supports resolutions of 480p and 720p, with videos up to 10 minutes long.
Returns:
Video generation result with URL and metadata
"""
try:
user_id = _require_user_id(current_user, "talking avatar generation")
logger.info(f"[Transform Studio] Talking avatar request from user {user_id}: resolution={request.resolution}")
# Convert request to service request
avatar_request = TalkingAvatarRequest(
image_base64=request.image_base64,
audio_base64=request.audio_base64,
resolution=request.resolution,
prompt=request.prompt,
mask_image_base64=request.mask_image_base64,
seed=request.seed,
)
# Generate video
result = await studio_manager.create_talking_avatar(avatar_request, user_id=user_id)
logger.info(f"[Transform Studio] ✅ Talking avatar completed: cost=${result['cost']:.2f}")
return TransformVideoResponse(**result)
except ValueError as e:
logger.error(f"[Transform Studio] ❌ Validation error: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"[Transform Studio] ❌ Unexpected error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Talking avatar generation failed: {str(e)}")
@router.post("/transform/estimate-cost", response_model=TransformCostEstimateResponse, summary="Estimate Transform Cost")
async def estimate_transform_cost(
request: TransformCostEstimateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
studio_manager: ImageStudioManager = Depends(get_studio_manager),
):
"""Estimate cost for transform operations.
Provides cost estimates before generation to help users make informed decisions.
Returns:
Cost estimation details
"""
try:
estimate = studio_manager.estimate_transform_cost(
operation=request.operation,
resolution=request.resolution,
duration=request.duration,
)
return TransformCostEstimateResponse(**estimate)
except ValueError as e:
logger.error(f"[Transform Studio] ❌ Cost estimation error: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"[Transform Studio] ❌ Error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/videos/{user_id}/{video_filename:path}", summary="Serve Transform Studio Video")
async def serve_transform_video(
user_id: str,
video_filename: str,
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
):
"""Serve a generated Transform Studio video file.
Args:
user_id: User ID from URL path
video_filename: Video filename
current_user: Authenticated user
Returns:
Video file response
"""
try:
# Verify user has access (must be the owner)
authenticated_user_id = _require_user_id(current_user, "video access")
if authenticated_user_id != user_id:
raise HTTPException(
status_code=403,
detail="Access denied: You can only access your own videos"
)
# Resolve video path
# __file__ is: backend/routers/image_studio.py
# We need: backend/transform_videos
base_dir = Path(__file__).parent.parent.parent
transform_videos_dir = base_dir / "transform_videos"
video_path = transform_videos_dir / user_id / video_filename
# Security: Ensure path is within transform_videos directory
# Prevent directory traversal attacks
try:
resolved_video_path = video_path.resolve()
resolved_base = transform_videos_dir.resolve()
# Check if video path is within base directory
resolved_video_path.relative_to(resolved_base)
except ValueError:
raise HTTPException(
status_code=403,
detail="Invalid video path: path traversal detected"
)
if not video_path.exists():
raise HTTPException(status_code=404, detail="Video not found")
return FileResponse(
path=str(video_path),
media_type="video/mp4",
filename=video_filename
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Transform Studio] Failed to serve video: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ====================
# HEALTH CHECK
# ====================

View File

@@ -8,9 +8,10 @@ proper error handling, monitoring, and documentation.
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Request
from fastapi.responses import JSONResponse
from typing import Dict, Any
from typing import Dict, Any, Optional
import time
from loguru import logger
from pathlib import Path
from models.linkedin_models import (
LinkedInPostRequest, LinkedInArticleRequest, LinkedInCarouselRequest,
@@ -19,11 +20,13 @@ from models.linkedin_models import (
LinkedInVideoScriptResponse, LinkedInCommentResponseResult
)
from services.linkedin_service import LinkedInService
from middleware.auth_middleware import get_current_user
from utils.text_asset_tracker import save_and_track_text_content
# Initialize the LinkedIn service instance
linkedin_service = LinkedInService()
from services.subscription.monitoring_middleware import DatabaseAPIMonitor
from services.database import get_db_session
from services.database import get_db as get_db_dependency
from sqlalchemy.orm import Session
# Initialize router
@@ -41,14 +44,8 @@ router = APIRouter(
monitor = DatabaseAPIMonitor()
def get_db():
"""Dependency to get database session."""
db = get_db_session()
try:
yield db
finally:
if db:
db.close()
# Use the proper database dependency from services.database
get_db = get_db_dependency
async def log_api_request(request: Request, db: Session, duration: float, status_code: int):
@@ -104,7 +101,8 @@ async def generate_post(
request: LinkedInPostRequest,
background_tasks: BackgroundTasks,
http_request: Request,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
):
"""Generate a LinkedIn post based on the provided parameters."""
start_time = time.time()
@@ -119,6 +117,13 @@ async def generate_post(
if not request.industry.strip():
raise HTTPException(status_code=422, detail="Industry cannot be empty")
# Extract user_id
user_id = None
if current_user:
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Generate post content
response = await linkedin_service.generate_linkedin_post(request)
@@ -131,6 +136,38 @@ async def generate_post(
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
if user_id and response.data and response.data.content:
try:
# Combine all text content
text_content = response.data.content
if response.data.call_to_action:
text_content += f"\n\nCall to Action: {response.data.call_to_action}"
if response.data.hashtags:
hashtag_text = " ".join([f"#{h.hashtag}" if isinstance(h, dict) else f"#{h.get('hashtag', '')}" for h in response.data.hashtags])
text_content += f"\n\nHashtags: {hashtag_text}"
save_and_track_text_content(
db=db,
user_id=user_id,
content=text_content,
source_module="linkedin_writer",
title=f"LinkedIn Post: {request.topic[:80]}",
description=f"LinkedIn post for {request.industry} industry",
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nTone: {request.tone}",
tags=["linkedin", "post", request.industry.lower().replace(' ', '_')],
asset_metadata={
"post_type": request.post_type.value if hasattr(request.post_type, 'value') else str(request.post_type),
"tone": request.tone.value if hasattr(request.tone, 'value') else str(request.tone),
"character_count": response.data.character_count,
"hashtag_count": len(response.data.hashtags),
"grounding_enabled": response.data.grounding_enabled if hasattr(response.data, 'grounding_enabled') else False
},
subdirectory="posts"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn post asset: {track_error}")
logger.info(f"Successfully generated LinkedIn post in {duration:.2f} seconds")
return response
@@ -174,7 +211,8 @@ async def generate_article(
request: LinkedInArticleRequest,
background_tasks: BackgroundTasks,
http_request: Request,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
):
"""Generate a LinkedIn article based on the provided parameters."""
start_time = time.time()
@@ -189,6 +227,13 @@ async def generate_article(
if not request.industry.strip():
raise HTTPException(status_code=422, detail="Industry cannot be empty")
# Extract user_id
user_id = None
if current_user:
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Generate article content
response = await linkedin_service.generate_linkedin_article(request)
@@ -201,6 +246,44 @@ async def generate_article(
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
if user_id and response.data:
try:
# Combine article content
text_content = f"# {response.data.title}\n\n"
text_content += response.data.content
if response.data.sections:
text_content += "\n\n## Sections:\n"
for section in response.data.sections:
if isinstance(section, dict):
text_content += f"\n### {section.get('heading', 'Section')}\n{section.get('content', '')}\n"
if response.data.seo_metadata:
text_content += f"\n\n## SEO Metadata\n{response.data.seo_metadata}\n"
save_and_track_text_content(
db=db,
user_id=user_id,
content=text_content,
source_module="linkedin_writer",
title=f"LinkedIn Article: {response.data.title[:80] if response.data.title else request.topic[:80]}",
description=f"LinkedIn article for {request.industry} industry",
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nTone: {request.tone}\nWord Count: {request.word_count}",
tags=["linkedin", "article", request.industry.lower().replace(' ', '_')],
asset_metadata={
"tone": request.tone.value if hasattr(request.tone, 'value') else str(request.tone),
"word_count": response.data.word_count,
"reading_time": response.data.reading_time,
"section_count": len(response.data.sections) if response.data.sections else 0,
"grounding_enabled": response.data.grounding_enabled if hasattr(response.data, 'grounding_enabled') else False
},
subdirectory="articles",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn article asset: {track_error}")
logger.info(f"Successfully generated LinkedIn article in {duration:.2f} seconds")
return response
@@ -243,7 +326,8 @@ async def generate_carousel(
request: LinkedInCarouselRequest,
background_tasks: BackgroundTasks,
http_request: Request,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
):
"""Generate a LinkedIn carousel based on the provided parameters."""
start_time = time.time()
@@ -261,6 +345,13 @@ async def generate_carousel(
if request.slide_count < 3 or request.slide_count > 15:
raise HTTPException(status_code=422, detail="Slide count must be between 3 and 15")
# Extract user_id
user_id = None
if current_user:
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Generate carousel content
response = await linkedin_service.generate_linkedin_carousel(request)
@@ -273,6 +364,36 @@ async def generate_carousel(
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
if user_id and response.data:
try:
# Combine carousel content
text_content = f"# {response.data.title}\n\n"
for slide in response.data.slides:
text_content += f"\n## Slide {slide.slide_number}: {slide.title}\n{slide.content}\n"
if slide.visual_elements:
text_content += f"\nVisual Elements: {', '.join(slide.visual_elements)}\n"
save_and_track_text_content(
db=db,
user_id=user_id,
content=text_content,
source_module="linkedin_writer",
title=f"LinkedIn Carousel: {response.data.title[:80] if response.data.title else request.topic[:80]}",
description=f"LinkedIn carousel for {request.industry} industry",
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nSlides: {getattr(request, 'number_of_slides', request.slide_count if hasattr(request, 'slide_count') else 5)}",
tags=["linkedin", "carousel", request.industry.lower().replace(' ', '_')],
asset_metadata={
"slide_count": len(response.data.slides),
"has_cover": response.data.cover_slide is not None,
"has_cta": response.data.cta_slide is not None
},
subdirectory="carousels",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn carousel asset: {track_error}")
logger.info(f"Successfully generated LinkedIn carousel in {duration:.2f} seconds")
return response
@@ -315,7 +436,8 @@ async def generate_video_script(
request: LinkedInVideoScriptRequest,
background_tasks: BackgroundTasks,
http_request: Request,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
):
"""Generate a LinkedIn video script based on the provided parameters."""
start_time = time.time()
@@ -330,9 +452,17 @@ async def generate_video_script(
if not request.industry.strip():
raise HTTPException(status_code=422, detail="Industry cannot be empty")
if request.video_length < 15 or request.video_length > 300:
video_duration = getattr(request, 'video_duration', getattr(request, 'video_length', 60))
if video_duration < 15 or video_duration > 300:
raise HTTPException(status_code=422, detail="Video length must be between 15 and 300 seconds")
# Extract user_id
user_id = None
if current_user:
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Generate video script content
response = await linkedin_service.generate_linkedin_video_script(request)
@@ -345,6 +475,47 @@ async def generate_video_script(
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
if user_id and response.data:
try:
# Combine video script content
text_content = f"# Video Script: {request.topic}\n\n"
text_content += f"## Hook\n{response.data.hook}\n\n"
text_content += "## Main Content\n"
for scene in response.data.main_content:
if isinstance(scene, dict):
text_content += f"\n### Scene {scene.get('scene_number', '')}\n"
text_content += f"{scene.get('content', '')}\n"
if scene.get('duration'):
text_content += f"Duration: {scene.get('duration')}s\n"
if scene.get('visual_notes'):
text_content += f"Visual Notes: {scene.get('visual_notes')}\n"
text_content += f"\n## Conclusion\n{response.data.conclusion}\n"
if response.data.captions:
text_content += f"\n## Captions\n" + "\n".join(response.data.captions) + "\n"
if response.data.thumbnail_suggestions:
text_content += f"\n## Thumbnail Suggestions\n" + "\n".join(response.data.thumbnail_suggestions) + "\n"
save_and_track_text_content(
db=db,
user_id=user_id,
content=text_content,
source_module="linkedin_writer",
title=f"LinkedIn Video Script: {request.topic[:80]}",
description=f"LinkedIn video script for {request.industry} industry",
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nDuration: {video_duration}s",
tags=["linkedin", "video_script", request.industry.lower().replace(' ', '_')],
asset_metadata={
"video_duration": video_duration,
"scene_count": len(response.data.main_content),
"has_captions": bool(response.data.captions)
},
subdirectory="video_scripts",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn video script asset: {track_error}")
logger.info(f"Successfully generated LinkedIn video script in {duration:.2f} seconds")
return response
@@ -387,7 +558,8 @@ async def generate_comment_response(
request: LinkedInCommentResponseRequest,
background_tasks: BackgroundTasks,
http_request: Request,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
):
"""Generate a LinkedIn comment response based on the provided parameters."""
start_time = time.time()
@@ -396,11 +568,21 @@ async def generate_comment_response(
logger.info("Received LinkedIn comment response generation request")
# Validate request
if not request.original_post.strip():
raise HTTPException(status_code=422, detail="Original post cannot be empty")
original_comment = getattr(request, 'original_comment', getattr(request, 'comment', ''))
post_context = getattr(request, 'post_context', getattr(request, 'original_post', ''))
if not request.comment.strip():
raise HTTPException(status_code=422, detail="Comment cannot be empty")
if not original_comment.strip():
raise HTTPException(status_code=422, detail="Original comment cannot be empty")
if not post_context.strip():
raise HTTPException(status_code=422, detail="Post context cannot be empty")
# Extract user_id
user_id = None
if current_user:
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Generate comment response
response = await linkedin_service.generate_linkedin_comment_response(request)
@@ -414,6 +596,38 @@ async def generate_comment_response(
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
if user_id and hasattr(response, 'response') and response.response:
try:
text_content = f"# Comment Response\n\n"
text_content += f"## Original Comment\n{original_comment}\n\n"
text_content += f"## Post Context\n{post_context}\n\n"
text_content += f"## Generated Response\n{response.response}\n"
if hasattr(response, 'alternatives') and response.alternatives:
text_content += f"\n## Alternative Responses\n"
for i, alt in enumerate(response.alternatives, 1):
text_content += f"\n### Alternative {i}\n{alt}\n"
save_and_track_text_content(
db=db,
user_id=user_id,
content=text_content,
source_module="linkedin_writer",
title=f"LinkedIn Comment Response: {original_comment[:60]}",
description=f"LinkedIn comment response for {request.industry} industry",
prompt=f"Original Comment: {original_comment}\nPost Context: {post_context}\nIndustry: {request.industry}",
tags=["linkedin", "comment_response", request.industry.lower().replace(' ', '_')],
asset_metadata={
"response_length": getattr(request, 'response_length', 'medium'),
"tone": request.tone.value if hasattr(request.tone, 'value') else str(request.tone),
"has_alternatives": hasattr(response, 'alternatives') and bool(response.alternatives)
},
subdirectory="comment_responses",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn comment response asset: {track_error}")
logger.info(f"Successfully generated LinkedIn comment response in {duration:.2f} seconds")
return response

View File

@@ -0,0 +1,640 @@
"""API endpoints for Product Marketing Suite."""
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from services.product_marketing import (
ProductMarketingOrchestrator,
BrandDNASyncService,
AssetAuditService,
ChannelPackService,
)
from services.product_marketing.campaign_storage import CampaignStorageService
from services.product_marketing.product_image_service import ProductImageService, ProductImageRequest
from middleware.auth_middleware import get_current_user
from utils.logger_utils import get_service_logger
from services.database import get_db
from sqlalchemy.orm import Session
logger = get_service_logger("api.product_marketing")
router = APIRouter(prefix="/api/product-marketing", tags=["product-marketing"])
# ====================
# REQUEST MODELS
# ====================
class CampaignCreateRequest(BaseModel):
"""Request to create a new campaign blueprint."""
campaign_name: str = Field(..., description="Campaign name")
goal: str = Field(..., description="Campaign goal (product_launch, awareness, conversion, etc.)")
kpi: Optional[str] = Field(None, description="Key performance indicator")
channels: List[str] = Field(..., description="Target channels (instagram, linkedin, tiktok, etc.)")
product_context: Optional[Dict[str, Any]] = Field(None, description="Product information")
class AssetProposalRequest(BaseModel):
"""Request to generate asset proposals."""
campaign_id: str = Field(..., description="Campaign ID")
product_context: Optional[Dict[str, Any]] = Field(None, description="Product information")
class AssetGenerateRequest(BaseModel):
"""Request to generate a specific asset."""
asset_proposal: Dict[str, Any] = Field(..., description="Asset proposal from generate_proposals")
product_context: Optional[Dict[str, Any]] = Field(None, description="Product information")
class AssetAuditRequest(BaseModel):
"""Request to audit uploaded assets."""
image_base64: str = Field(..., description="Base64 encoded image")
asset_metadata: Optional[Dict[str, Any]] = Field(None, description="Asset metadata")
# ====================
# DEPENDENCY
# ====================
def get_orchestrator() -> ProductMarketingOrchestrator:
"""Get Product Marketing Orchestrator instance."""
return ProductMarketingOrchestrator()
def get_campaign_storage() -> CampaignStorageService:
"""Get Campaign Storage Service instance."""
return CampaignStorageService()
def _require_user_id(current_user: Dict[str, Any], operation: str) -> str:
"""Ensure user_id is available for protected operations."""
user_id = current_user.get("sub") or current_user.get("user_id") or current_user.get("id")
if not user_id:
logger.error(
"[Product Marketing] ❌ Missing user_id for %s operation - blocking request",
operation,
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authenticated user required for product marketing operations.",
)
return str(user_id)
# ====================
# CAMPAIGN ENDPOINTS
# ====================
@router.post("/campaigns/validate-preflight", summary="Validate Campaign Pre-flight")
async def validate_campaign_preflight(
request: CampaignCreateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
orchestrator: ProductMarketingOrchestrator = Depends(get_orchestrator)
):
"""Validate campaign blueprint against subscription limits before creation.
This endpoint:
- Creates a temporary blueprint to estimate costs
- Validates subscription limits
- Returns cost estimates and validation results
- Does NOT save anything to database
"""
try:
user_id = _require_user_id(current_user, "campaign pre-flight validation")
logger.info(f"[Product Marketing] Pre-flight validation for user {user_id}")
# Create temporary blueprint for validation (not saved)
campaign_data = {
"campaign_name": request.campaign_name or "Temporary Campaign",
"goal": request.goal,
"kpi": request.kpi,
"channels": request.channels,
}
blueprint = orchestrator.create_campaign_blueprint(user_id, campaign_data)
# Run pre-flight validation
validation_result = orchestrator.validate_campaign_preflight(user_id, blueprint)
logger.info(f"[Product Marketing] ✅ Pre-flight validation completed: can_proceed={validation_result.get('can_proceed')}")
return validation_result
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error in pre-flight validation: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Pre-flight validation failed: {str(e)}")
@router.post("/campaigns/create-blueprint", summary="Create Campaign Blueprint")
async def create_campaign_blueprint(
request: CampaignCreateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
orchestrator: ProductMarketingOrchestrator = Depends(get_orchestrator)
):
"""Create a campaign blueprint with personalized asset nodes.
This endpoint:
- Uses onboarding data to personalize the blueprint
- Generates campaign phases (teaser, launch, nurture)
- Creates asset nodes for each phase and channel
- Returns blueprint ready for AI proposal generation
"""
try:
user_id = _require_user_id(current_user, "campaign blueprint creation")
logger.info(f"[Product Marketing] Creating blueprint for user {user_id}: {request.campaign_name}")
campaign_data = {
"campaign_name": request.campaign_name,
"goal": request.goal,
"kpi": request.kpi,
"channels": request.channels,
}
blueprint = orchestrator.create_campaign_blueprint(user_id, campaign_data)
# Convert blueprint to dict for JSON response
blueprint_dict = {
"campaign_id": blueprint.campaign_id,
"campaign_name": blueprint.campaign_name,
"goal": blueprint.goal,
"kpi": blueprint.kpi,
"phases": blueprint.phases,
"asset_nodes": [
{
"asset_id": node.asset_id,
"asset_type": node.asset_type,
"channel": node.channel,
"status": node.status,
}
for node in blueprint.asset_nodes
],
"channels": blueprint.channels,
"status": blueprint.status,
}
# Save to database
campaign_storage = get_campaign_storage()
campaign_storage.save_campaign(user_id, blueprint_dict)
logger.info(f"[Product Marketing] ✅ Blueprint created and saved: {blueprint.campaign_id}")
return blueprint_dict
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error creating blueprint: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Campaign blueprint creation failed: {str(e)}")
@router.post("/campaigns/{campaign_id}/generate-proposals", summary="Generate Asset Proposals")
async def generate_asset_proposals(
campaign_id: str,
request: AssetProposalRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
orchestrator: ProductMarketingOrchestrator = Depends(get_orchestrator)
):
"""Generate AI proposals for all assets in a campaign blueprint.
This endpoint:
- Uses specialized marketing prompts with brand DNA
- Recommends templates, providers, and settings
- Provides cost estimates
- Returns proposals ready for user approval
"""
try:
user_id = _require_user_id(current_user, "asset proposal generation")
logger.info(f"[Product Marketing] Generating proposals for campaign {campaign_id}")
# Fetch blueprint from database
campaign_storage = get_campaign_storage()
campaign = campaign_storage.get_campaign(user_id, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Reconstruct blueprint from database
from services.product_marketing.orchestrator import CampaignBlueprint, CampaignAssetNode
asset_nodes = []
if campaign.asset_nodes:
for node_data in campaign.asset_nodes:
asset_nodes.append(CampaignAssetNode(
asset_id=node_data.get('asset_id'),
asset_type=node_data.get('asset_type'),
channel=node_data.get('channel'),
status=node_data.get('status', 'draft'),
))
blueprint = CampaignBlueprint(
campaign_id=campaign.campaign_id,
campaign_name=campaign.campaign_name,
goal=campaign.goal,
kpi=campaign.kpi,
channels=campaign.channels or [],
asset_nodes=asset_nodes,
)
proposals = orchestrator.generate_asset_proposals(
user_id=user_id,
blueprint=blueprint,
product_context=request.product_context,
)
# Save proposals to database
try:
campaign_storage.save_proposals(user_id, campaign_id, proposals)
logger.info(f"[Product Marketing] ✅ Saved {proposals['total_assets']} proposals to database")
except Exception as save_error:
logger.error(f"[Product Marketing] ⚠️ Failed to save proposals to database: {str(save_error)}")
# Continue even if save fails - proposals are still returned to user
# This allows the workflow to continue, but proposals won't persist
logger.info(f"[Product Marketing] ✅ Generated {proposals['total_assets']} proposals")
return proposals
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error generating proposals: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Asset proposal generation failed: {str(e)}")
@router.post("/assets/generate", summary="Generate Asset")
async def generate_asset(
request: AssetGenerateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
orchestrator: ProductMarketingOrchestrator = Depends(get_orchestrator)
):
"""Generate a single asset using Image Studio APIs.
This endpoint:
- Reuses existing Image Studio APIs
- Applies specialized marketing prompts
- Automatically tracks assets in Asset Library
- Validates subscription limits
"""
try:
user_id = _require_user_id(current_user, "asset generation")
logger.info(f"[Product Marketing] Generating asset for user {user_id}")
result = await orchestrator.generate_asset(
user_id=user_id,
asset_proposal=request.asset_proposal,
product_context=request.product_context,
)
logger.info(f"[Product Marketing] ✅ Asset generated successfully")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error generating asset: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Asset generation failed: {str(e)}")
# ====================
# BRAND DNA ENDPOINTS
# ====================
@router.get("/brand-dna", summary="Get Brand DNA Tokens")
async def get_brand_dna(
current_user: Dict[str, Any] = Depends(get_current_user),
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
):
"""Get brand DNA tokens for the authenticated user.
Returns normalized brand DNA from onboarding and persona data.
"""
try:
user_id = _require_user_id(current_user, "brand DNA retrieval")
brand_tokens = brand_dna_sync.get_brand_dna_tokens(user_id)
return {"brand_dna": brand_tokens}
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error getting brand DNA: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/brand-dna/channel/{channel}", summary="Get Channel-Specific Brand DNA")
async def get_channel_brand_dna(
channel: str,
current_user: Dict[str, Any] = Depends(get_current_user),
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
):
"""Get channel-specific brand DNA adaptations."""
try:
user_id = _require_user_id(current_user, "channel brand DNA retrieval")
channel_dna = brand_dna_sync.get_channel_specific_dna(user_id, channel)
return {"channel": channel, "brand_dna": channel_dna}
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error getting channel DNA: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ====================
# ASSET AUDIT ENDPOINTS
# ====================
@router.post("/assets/audit", summary="Audit Asset")
async def audit_asset(
request: AssetAuditRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
asset_audit: AssetAuditService = Depends(lambda: AssetAuditService())
):
"""Audit an uploaded asset and get enhancement recommendations."""
try:
user_id = _require_user_id(current_user, "asset audit")
audit_result = asset_audit.audit_asset(
request.image_base64,
request.asset_metadata,
)
return audit_result
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error auditing asset: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ====================
# CHANNEL PACK ENDPOINTS
# ====================
@router.get("/channels/{channel}/pack", summary="Get Channel Pack")
async def get_channel_pack(
channel: str,
asset_type: str = "social_post",
current_user: Dict[str, Any] = Depends(get_current_user),
channel_pack: ChannelPackService = Depends(lambda: ChannelPackService())
):
"""Get channel-specific pack configuration with templates and optimization tips."""
try:
pack = channel_pack.get_channel_pack(channel, asset_type)
return pack
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error getting channel pack: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ====================
# CAMPAIGN LISTING & RETRIEVAL
# ====================
@router.get("/campaigns", summary="List Campaigns")
async def list_campaigns(
status: Optional[str] = None,
current_user: Dict[str, Any] = Depends(get_current_user),
campaign_storage: CampaignStorageService = Depends(get_campaign_storage)
):
"""List all campaigns for the authenticated user."""
try:
user_id = _require_user_id(current_user, "list campaigns")
campaigns = campaign_storage.list_campaigns(user_id, status=status)
return {
"campaigns": [
{
"campaign_id": c.campaign_id,
"campaign_name": c.campaign_name,
"goal": c.goal,
"kpi": c.kpi,
"status": c.status,
"channels": c.channels,
"phases": c.phases,
"asset_nodes": c.asset_nodes,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
for c in campaigns
],
"total": len(campaigns),
}
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error listing campaigns: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/campaigns/{campaign_id}", summary="Get Campaign")
async def get_campaign(
campaign_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
campaign_storage: CampaignStorageService = Depends(get_campaign_storage)
):
"""Get a specific campaign by ID."""
try:
user_id = _require_user_id(current_user, "get campaign")
campaign = campaign_storage.get_campaign(user_id, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
return {
"campaign_id": campaign.campaign_id,
"campaign_name": campaign.campaign_name,
"goal": campaign.goal,
"kpi": campaign.kpi,
"status": campaign.status,
"channels": campaign.channels,
"phases": campaign.phases,
"asset_nodes": campaign.asset_nodes,
"product_context": campaign.product_context,
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error getting campaign: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/campaigns/{campaign_id}/proposals", summary="Get Campaign Proposals")
async def get_campaign_proposals(
campaign_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
campaign_storage: CampaignStorageService = Depends(get_campaign_storage)
):
"""Get proposals for a campaign."""
try:
user_id = _require_user_id(current_user, "get proposals")
proposals = campaign_storage.get_proposals(user_id, campaign_id)
proposals_dict = {}
for proposal in proposals:
proposals_dict[proposal.asset_node_id] = {
"asset_id": proposal.asset_node_id,
"asset_type": proposal.asset_type,
"channel": proposal.channel,
"proposed_prompt": proposal.proposed_prompt,
"recommended_template": proposal.recommended_template,
"recommended_provider": proposal.recommended_provider,
"cost_estimate": proposal.cost_estimate,
"concept_summary": proposal.concept_summary,
"status": proposal.status,
}
return {
"proposals": proposals_dict,
"total_assets": len(proposals),
}
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error getting proposals: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ====================
# PRODUCT ASSET ENDPOINTS (Product Marketing Suite - Product Assets)
# ====================
class ProductPhotoshootRequest(BaseModel):
"""Request for product image photoshoot generation."""
product_name: str = Field(..., description="Product name")
product_description: str = Field(..., description="Product description")
environment: str = Field(default="studio", description="Environment: studio, lifestyle, outdoor, minimalist, luxury")
background_style: str = Field(default="white", description="Background: white, transparent, lifestyle, branded")
lighting: str = Field(default="natural", description="Lighting: natural, studio, dramatic, soft")
product_variant: Optional[str] = Field(None, description="Product variant (color, size, etc.)")
angle: Optional[str] = Field(None, description="Product angle: front, side, top, 360")
style: str = Field(default="photorealistic", description="Style: photorealistic, minimalist, luxury, technical")
resolution: str = Field(default="1024x1024", description="Resolution (e.g., 1024x1024, 1280x720)")
num_variations: int = Field(default=1, description="Number of variations to generate")
brand_colors: Optional[List[str]] = Field(None, description="Brand color palette")
additional_context: Optional[str] = Field(None, description="Additional context for generation")
def get_product_image_service() -> ProductImageService:
"""Get Product Image Service instance."""
return ProductImageService()
@router.post("/products/photoshoot", summary="Generate Product Image")
async def generate_product_image(
request: ProductPhotoshootRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
product_image_service: ProductImageService = Depends(get_product_image_service),
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
):
"""Generate professional product images using AI.
This endpoint:
- Generates product images optimized for e-commerce
- Supports multiple environments and styles
- Integrates with brand DNA for personalization
- Automatically saves to Asset Library
"""
try:
user_id = _require_user_id(current_user, "product image generation")
logger.info(f"[Product Marketing] Generating product image for '{request.product_name}'")
# Get brand DNA for personalization
brand_context = None
try:
brand_dna = brand_dna_sync.get_brand_dna_tokens(user_id)
brand_context = {
"visual_identity": brand_dna.get("visual_identity", {}),
"persona": brand_dna.get("persona", {}),
}
except Exception as brand_error:
logger.warning(f"[Product Marketing] Could not load brand DNA: {str(brand_error)}")
# Convert request to service request
service_request = ProductImageRequest(
product_name=request.product_name,
product_description=request.product_description,
environment=request.environment,
background_style=request.background_style,
lighting=request.lighting,
product_variant=request.product_variant,
angle=request.angle,
style=request.style,
resolution=request.resolution,
num_variations=request.num_variations,
brand_colors=request.brand_colors,
additional_context=request.additional_context,
)
# Generate product image
result = await product_image_service.generate_product_image(
request=service_request,
user_id=user_id,
brand_context=brand_context,
)
if not result.success:
raise HTTPException(status_code=500, detail=result.error or "Product image generation failed")
logger.info(f"[Product Marketing] ✅ Generated product image: {result.asset_id}")
# Return result (image_bytes will be served via separate endpoint)
return {
"success": True,
"product_name": result.product_name,
"image_url": result.image_url,
"asset_id": result.asset_id,
"provider": result.provider,
"model": result.model,
"cost": result.cost,
"generation_time": result.generation_time,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error generating product image: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Product image generation failed: {str(e)}")
@router.get("/products/images/{filename}", summary="Serve Product Image")
async def serve_product_image(
filename: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Serve generated product images."""
try:
from fastapi.responses import FileResponse
from pathlib import Path
_require_user_id(current_user, "serving product image")
# Locate image file
base_dir = Path(__file__).parent.parent.parent
image_path = base_dir / "product_images" / filename
if not image_path.exists():
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(
path=str(image_path),
media_type="image/png",
filename=filename
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Product Marketing] ❌ Error serving product image: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# ====================
# HEALTH CHECK
# ====================
@router.get("/health", summary="Health Check")
async def health_check():
"""Health check endpoint for Product Marketing Suite."""
return {
"status": "healthy",
"service": "product_marketing",
"version": "1.0.0",
"modules": {
"orchestrator": "available",
"prompt_builder": "available",
"brand_dna_sync": "available",
"asset_audit": "available",
"channel_pack": "available",
"product_image_service": "available",
}
}