AI Researcher and Video Studio implementation complete
This commit is contained in:
@@ -7,6 +7,9 @@ from .asset_audit import AssetAuditService
|
||||
from .channel_pack import ChannelPackService
|
||||
from .campaign_storage import CampaignStorageService
|
||||
from .product_image_service import ProductImageService
|
||||
from .product_animation_service import ProductAnimationService, ProductAnimationRequest
|
||||
from .product_video_service import ProductVideoService, ProductVideoRequest
|
||||
from .product_avatar_service import ProductAvatarService, ProductAvatarRequest
|
||||
|
||||
__all__ = [
|
||||
"ProductMarketingOrchestrator",
|
||||
@@ -16,5 +19,11 @@ __all__ = [
|
||||
"ChannelPackService",
|
||||
"CampaignStorageService",
|
||||
"ProductImageService",
|
||||
"ProductAnimationService",
|
||||
"ProductAnimationRequest",
|
||||
"ProductVideoService",
|
||||
"ProductVideoRequest",
|
||||
"ProductAvatarService",
|
||||
"ProductAvatarRequest",
|
||||
]
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ class ProductMarketingOrchestrator:
|
||||
"asset_id": asset_node.asset_id,
|
||||
"asset_type": asset_node.asset_type,
|
||||
"channel": asset_node.channel,
|
||||
"campaign_id": blueprint.campaign_id, # Include campaign_id for tracking
|
||||
"proposed_prompt": enhanced_prompt,
|
||||
"recommended_template": recommended_template.get('id') if recommended_template else None,
|
||||
"recommended_provider": recommended_template.get('recommended_provider', 'wavespeed') if recommended_template else 'wavespeed',
|
||||
@@ -170,6 +171,67 @@ class ProductMarketingOrchestrator:
|
||||
"concept_summary": self._generate_concept_summary(enhanced_prompt),
|
||||
}
|
||||
|
||||
elif asset_node.asset_type == "video":
|
||||
# Video asset proposals - determine if animation (image-to-video) or demo (text-to-video)
|
||||
# Default to animation if we have product image, otherwise demo
|
||||
video_subtype = asset_proposal.get('video_subtype', 'animation') if 'asset_proposal' in locals() else 'demo'
|
||||
|
||||
# For demo videos (text-to-video), we need product description
|
||||
if video_subtype == "demo" or not product_context or not product_context.get('product_image_base64'):
|
||||
# Text-to-video demo video
|
||||
video_type = "demo" # Default, can be customized
|
||||
if asset_node.channel in ["tiktok", "instagram"]:
|
||||
video_type = "storytelling" # Storytelling for social media
|
||||
elif asset_node.channel in ["linkedin", "youtube"]:
|
||||
video_type = "feature_highlight" # Feature highlights for professional
|
||||
|
||||
# Estimate cost for text-to-video (WAN 2.5: $0.05-$0.15/second)
|
||||
duration = 10 # Default 10s for demo videos
|
||||
resolution = "720p" # Default
|
||||
cost_per_second = 0.10 if resolution == "720p" else (0.15 if resolution == "1080p" else 0.05)
|
||||
cost_estimate = duration * cost_per_second
|
||||
|
||||
proposals[asset_node.asset_id] = {
|
||||
"asset_id": asset_node.asset_id,
|
||||
"asset_type": asset_node.asset_type,
|
||||
"video_subtype": "demo", # Text-to-video
|
||||
"channel": asset_node.channel,
|
||||
"campaign_id": blueprint.campaign_id,
|
||||
"video_type": video_type,
|
||||
"duration": duration,
|
||||
"resolution": resolution,
|
||||
"cost_estimate": cost_estimate,
|
||||
"concept_summary": f"Product {video_type} video optimized for {asset_node.channel}",
|
||||
"note": "Text-to-video demo - requires product description",
|
||||
}
|
||||
else:
|
||||
# Image-to-video animation
|
||||
animation_type = "reveal" # Default
|
||||
if asset_node.channel in ["tiktok", "instagram", "youtube"]:
|
||||
animation_type = "demo" # Demo animations for social media
|
||||
elif asset_node.channel in ["linkedin", "facebook"]:
|
||||
animation_type = "reveal" # Professional reveal for B2B
|
||||
|
||||
# Estimate cost for image-to-video (WAN 2.5: $0.05-$0.15/second)
|
||||
duration = 5 # Default 5s for animations
|
||||
resolution = "720p" # Default
|
||||
cost_per_second = 0.10 if resolution == "720p" else (0.15 if resolution == "1080p" else 0.05)
|
||||
cost_estimate = duration * cost_per_second
|
||||
|
||||
proposals[asset_node.asset_id] = {
|
||||
"asset_id": asset_node.asset_id,
|
||||
"asset_type": asset_node.asset_type,
|
||||
"video_subtype": "animation", # Image-to-video
|
||||
"channel": asset_node.channel,
|
||||
"campaign_id": blueprint.campaign_id,
|
||||
"animation_type": animation_type,
|
||||
"duration": duration,
|
||||
"resolution": resolution,
|
||||
"cost_estimate": cost_estimate,
|
||||
"concept_summary": f"Product {animation_type} animation optimized for {asset_node.channel}",
|
||||
"note": "Requires product image - will be provided during generation",
|
||||
}
|
||||
|
||||
elif asset_node.asset_type == "text":
|
||||
base_request = f"Write {asset_node.channel} {asset_node.asset_type} for product launch"
|
||||
enhanced_prompt = self.prompt_builder.build_marketing_copy_prompt(
|
||||
@@ -184,6 +246,7 @@ class ProductMarketingOrchestrator:
|
||||
"asset_id": asset_node.asset_id,
|
||||
"asset_type": asset_node.asset_type,
|
||||
"channel": asset_node.channel,
|
||||
"campaign_id": blueprint.campaign_id, # Include campaign_id for tracking
|
||||
"proposed_prompt": enhanced_prompt,
|
||||
"cost_estimate": 0.0, # Text generation cost is minimal
|
||||
"concept_summary": "Marketing copy optimized for channel and persona",
|
||||
@@ -242,6 +305,124 @@ class ProductMarketingOrchestrator:
|
||||
],
|
||||
}
|
||||
|
||||
elif asset_type == "video":
|
||||
# Check video subtype: "animation" (image-to-video) or "demo" (text-to-video)
|
||||
video_subtype = asset_proposal.get('video_subtype', 'animation')
|
||||
|
||||
if video_subtype == "demo":
|
||||
# Text-to-video: Product demo video from description
|
||||
from .product_video_service import ProductVideoService, ProductVideoRequest
|
||||
|
||||
# Get product info from context
|
||||
product_name = product_context.get('product_name', 'Product') if product_context else 'Product'
|
||||
product_description = product_context.get('product_description', '') if product_context else ''
|
||||
|
||||
if not product_description:
|
||||
raise ValueError("Product description required for text-to-video demo generation")
|
||||
|
||||
# Get brand context
|
||||
brand_dna = self.brand_dna_sync.get_brand_dna_tokens(user_id)
|
||||
brand_context = {
|
||||
"visual_identity": brand_dna.get("visual_identity", {}),
|
||||
"persona": brand_dna.get("persona", {}),
|
||||
}
|
||||
|
||||
# Get video type from proposal or default
|
||||
video_type = asset_proposal.get('video_type', 'demo')
|
||||
|
||||
# Create video service
|
||||
video_service = ProductVideoService()
|
||||
|
||||
# Create video request
|
||||
video_request = ProductVideoRequest(
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
video_type=video_type,
|
||||
resolution=asset_proposal.get('resolution', '720p'),
|
||||
duration=asset_proposal.get('duration', 10),
|
||||
audio_base64=asset_proposal.get('audio_base64'),
|
||||
brand_context=brand_context,
|
||||
additional_context=asset_proposal.get('additional_context'),
|
||||
)
|
||||
|
||||
# Generate video using unified ai_video_generate()
|
||||
result = await video_service.generate_product_video(video_request, user_id)
|
||||
|
||||
# Extract campaign_id for metadata
|
||||
campaign_id = asset_proposal.get('campaign_id')
|
||||
asset_id = asset_proposal.get('asset_id', '')
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"asset_type": "video",
|
||||
"video_subtype": "demo",
|
||||
"video_url": result.get('file_url'),
|
||||
"video_filename": result.get('filename'),
|
||||
"cost": result.get('cost', 0.0),
|
||||
"video_type": video_type,
|
||||
"campaign_id": campaign_id,
|
||||
"asset_id": asset_id,
|
||||
}
|
||||
|
||||
else:
|
||||
# Image-to-video: Product animation
|
||||
from .product_animation_service import ProductAnimationService, ProductAnimationRequest
|
||||
|
||||
# Get product image from proposal or product context
|
||||
product_image_base64 = asset_proposal.get('product_image_base64')
|
||||
if not product_image_base64 and product_context:
|
||||
product_image_base64 = product_context.get('product_image_base64')
|
||||
|
||||
if not product_image_base64:
|
||||
raise ValueError("Product image required for image-to-video animation generation")
|
||||
|
||||
# Get animation type from proposal or default to "reveal"
|
||||
animation_type = asset_proposal.get('animation_type', 'reveal')
|
||||
product_name = product_context.get('product_name', 'Product') if product_context else 'Product'
|
||||
product_description = product_context.get('product_description') if product_context else None
|
||||
|
||||
# Get brand context
|
||||
brand_dna = self.brand_dna_sync.get_brand_dna_tokens(user_id)
|
||||
brand_context = {
|
||||
"visual_identity": brand_dna.get("visual_identity", {}),
|
||||
"persona": brand_dna.get("persona", {}),
|
||||
}
|
||||
|
||||
# Create animation service
|
||||
animation_service = ProductAnimationService()
|
||||
|
||||
# Create animation request
|
||||
animation_request = ProductAnimationRequest(
|
||||
product_image_base64=product_image_base64,
|
||||
animation_type=animation_type,
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
resolution=asset_proposal.get('resolution', '720p'),
|
||||
duration=asset_proposal.get('duration', 5),
|
||||
audio_base64=asset_proposal.get('audio_base64'),
|
||||
brand_context=brand_context,
|
||||
additional_context=asset_proposal.get('additional_context'),
|
||||
)
|
||||
|
||||
# Generate video
|
||||
result = await animation_service.animate_product(animation_request, user_id)
|
||||
|
||||
# Extract campaign_id for metadata
|
||||
campaign_id = asset_proposal.get('campaign_id')
|
||||
asset_id = asset_proposal.get('asset_id', '')
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"asset_type": "video",
|
||||
"video_subtype": "animation",
|
||||
"video_url": result.get('video_url'),
|
||||
"video_filename": result.get('filename'),
|
||||
"cost": result.get('cost', 0.0),
|
||||
"animation_type": animation_type,
|
||||
"campaign_id": campaign_id,
|
||||
"asset_id": asset_id,
|
||||
}
|
||||
|
||||
elif asset_type == "text":
|
||||
# Import text generation service and tracker
|
||||
import asyncio
|
||||
@@ -457,6 +638,10 @@ Return only the final copy text without explanations or markdown formatting."""
|
||||
if asset_type == "image":
|
||||
# Premium quality image: ~5-6 credits
|
||||
return 5.0
|
||||
elif asset_type == "video":
|
||||
# WAN 2.5 Image-to-Video: $0.05-$0.15/second
|
||||
# Default: 5 seconds at 720p = $0.50
|
||||
return 0.50
|
||||
elif asset_type == "text":
|
||||
return 0.0 # Text generation is typically included
|
||||
else:
|
||||
|
||||
221
backend/services/product_marketing/product_animation_service.py
Normal file
221
backend/services/product_marketing/product_animation_service.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Product Animation Service
|
||||
Handles product animation workflows using Transform Studio (WAN 2.5 Image-to-Video).
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from loguru import logger
|
||||
from dataclasses import dataclass
|
||||
|
||||
from services.image_studio.transform_service import TransformStudioService, TransformImageToVideoRequest
|
||||
from services.image_studio.studio_manager import ImageStudioManager
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
logger = get_service_logger("product_marketing.animation")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductAnimationRequest:
|
||||
"""Request for product animation."""
|
||||
product_image_base64: str
|
||||
animation_type: str # "reveal", "rotation", "demo", "lifestyle"
|
||||
product_name: str
|
||||
product_description: Optional[str] = None
|
||||
resolution: str = "720p" # 480p, 720p, 1080p
|
||||
duration: int = 5 # 5 or 10 seconds
|
||||
audio_base64: Optional[str] = None
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
additional_context: Optional[str] = None
|
||||
|
||||
|
||||
class ProductAnimationService:
|
||||
"""Service for product animation workflows."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Product Animation Service."""
|
||||
self.transform_service = TransformStudioService()
|
||||
self.image_studio = ImageStudioManager()
|
||||
logger.info("[Product Animation Service] Initialized")
|
||||
|
||||
def _build_animation_prompt(
|
||||
self,
|
||||
animation_type: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
brand_context: Optional[Dict[str, Any]],
|
||||
additional_context: Optional[str]
|
||||
) -> str:
|
||||
"""
|
||||
Build animation prompt based on animation type and product context.
|
||||
|
||||
Args:
|
||||
animation_type: Type of animation (reveal, rotation, demo, lifestyle)
|
||||
product_name: Product name
|
||||
product_description: Product description
|
||||
brand_context: Brand DNA context
|
||||
additional_context: Additional context
|
||||
|
||||
Returns:
|
||||
Animation prompt
|
||||
"""
|
||||
base_prompt = f"{product_name}"
|
||||
if product_description:
|
||||
base_prompt += f": {product_description}"
|
||||
|
||||
# Animation-specific prompts
|
||||
animation_prompts = {
|
||||
"reveal": f"{base_prompt} elegantly revealing, smooth camera movement, professional product showcase, cinematic lighting",
|
||||
"rotation": f"{base_prompt} slowly rotating 360 degrees, smooth rotation, professional product photography, studio lighting, clean background",
|
||||
"demo": f"{base_prompt} in use, demonstrating features, dynamic movement, engaging presentation, professional product demo",
|
||||
"lifestyle": f"{base_prompt} in realistic lifestyle setting, natural environment, authentic use case, relatable scenario",
|
||||
}
|
||||
|
||||
prompt = animation_prompts.get(animation_type, base_prompt)
|
||||
|
||||
# Add brand context if available
|
||||
if brand_context:
|
||||
visual_identity = brand_context.get("visual_identity", {})
|
||||
if visual_identity.get("color_palette"):
|
||||
colors = ", ".join(visual_identity["color_palette"][:3]) # First 3 colors
|
||||
prompt += f", {colors} color scheme"
|
||||
|
||||
if visual_identity.get("style_guidelines"):
|
||||
style = visual_identity["style_guidelines"].get("aesthetic", "")
|
||||
if style:
|
||||
prompt += f", {style} style"
|
||||
|
||||
# Add additional context
|
||||
if additional_context:
|
||||
prompt += f", {additional_context}"
|
||||
|
||||
return prompt
|
||||
|
||||
async def animate_product(
|
||||
self,
|
||||
request: ProductAnimationRequest,
|
||||
user_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Animate a product image into a video.
|
||||
|
||||
Args:
|
||||
request: Product animation request
|
||||
user_id: User ID for tracking
|
||||
|
||||
Returns:
|
||||
Animation result with video URL and metadata
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"[Product Animation] Animating product '{request.product_name}' "
|
||||
f"with type '{request.animation_type}' for user {user_id}"
|
||||
)
|
||||
|
||||
# Build animation prompt
|
||||
animation_prompt = self._build_animation_prompt(
|
||||
animation_type=request.animation_type,
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
brand_context=request.brand_context,
|
||||
additional_context=request.additional_context
|
||||
)
|
||||
|
||||
# Create transform request
|
||||
transform_request = TransformImageToVideoRequest(
|
||||
image_base64=request.product_image_base64,
|
||||
prompt=animation_prompt,
|
||||
audio_base64=request.audio_base64,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration,
|
||||
enable_prompt_expansion=True, # Expand prompt for better results
|
||||
)
|
||||
|
||||
# Generate video using Transform Studio
|
||||
result = await self.transform_service.transform_image_to_video(
|
||||
request=transform_request,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Add product-specific metadata
|
||||
result["product_name"] = request.product_name
|
||||
result["animation_type"] = request.animation_type
|
||||
result["source_module"] = "product_marketing"
|
||||
|
||||
logger.info(
|
||||
f"[Product Animation] ✅ Product animation completed: "
|
||||
f"cost=${result.get('cost', 0):.2f}, video_url={result.get('video_url', 'N/A')}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Animation] ❌ Error animating product: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def create_product_reveal(
|
||||
self,
|
||||
product_image_base64: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
duration: int = 5,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product reveal animation."""
|
||||
request = ProductAnimationRequest(
|
||||
product_image_base64=product_image_base64,
|
||||
animation_type="reveal",
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.animate_product(request, user_id)
|
||||
|
||||
async def create_product_rotation(
|
||||
self,
|
||||
product_image_base64: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
duration: int = 10, # Longer for full rotation
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create 360° product rotation animation."""
|
||||
request = ProductAnimationRequest(
|
||||
product_image_base64=product_image_base64,
|
||||
animation_type="rotation",
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.animate_product(request, user_id)
|
||||
|
||||
async def create_product_demo(
|
||||
self,
|
||||
product_image_base64: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
duration: int = 10,
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product demo video."""
|
||||
request = ProductAnimationRequest(
|
||||
product_image_base64=product_image_base64,
|
||||
animation_type="demo",
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
audio_base64=audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.animate_product(request, user_id)
|
||||
380
backend/services/product_marketing/product_avatar_service.py
Normal file
380
backend/services/product_marketing/product_avatar_service.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
Product Avatar Service
|
||||
Handles product explainer video generation using InfiniteTalk (talking avatars).
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from loguru import logger
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import os
|
||||
import base64
|
||||
|
||||
from services.image_studio.infinitetalk_adapter import InfiniteTalkService
|
||||
from services.story_writer.audio_generation_service import StoryAudioGenerationService
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
logger = get_service_logger("product_marketing.avatar")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductAvatarRequest:
|
||||
"""Request for product explainer video with talking avatar."""
|
||||
avatar_image_base64: str # Product image, brand spokesperson, or brand mascot
|
||||
script_text: Optional[str] = None # Text script to convert to audio
|
||||
audio_base64: Optional[str] = None # Pre-generated audio (alternative to script_text)
|
||||
product_name: str = "Product"
|
||||
product_description: Optional[str] = None
|
||||
explainer_type: str = "product_overview" # product_overview, feature_explainer, tutorial, brand_message
|
||||
resolution: str = "720p" # 480p or 720p
|
||||
prompt: Optional[str] = None # Optional prompt for expression/style
|
||||
mask_image_base64: Optional[str] = None # Optional mask for animatable regions
|
||||
seed: Optional[int] = None
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
additional_context: Optional[str] = None
|
||||
|
||||
|
||||
class ProductAvatarService:
|
||||
"""Service for product explainer video generation using InfiniteTalk."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Product Avatar Service."""
|
||||
self.infinitetalk_service = InfiniteTalkService()
|
||||
self.audio_service = StoryAudioGenerationService()
|
||||
logger.info("[Product Avatar Service] Initialized")
|
||||
|
||||
def _build_avatar_prompt(
|
||||
self,
|
||||
explainer_type: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
brand_context: Optional[Dict[str, Any]],
|
||||
additional_context: Optional[str]
|
||||
) -> str:
|
||||
"""
|
||||
Build avatar prompt based on explainer type and product context.
|
||||
|
||||
Args:
|
||||
explainer_type: Type of explainer (product_overview, feature_explainer, tutorial, brand_message)
|
||||
product_name: Product name
|
||||
product_description: Product description
|
||||
brand_context: Brand DNA context
|
||||
additional_context: Additional context
|
||||
|
||||
Returns:
|
||||
Avatar animation prompt
|
||||
"""
|
||||
base_description = f"{product_name}"
|
||||
if product_description:
|
||||
base_description += f": {product_description}"
|
||||
|
||||
# Explainer type-specific prompts
|
||||
explainer_prompts = {
|
||||
"product_overview": (
|
||||
f"Professional product presentation of {base_description}, "
|
||||
f"engaging and informative, clear communication, confident expression, "
|
||||
f"professional setting, modern and clean aesthetic"
|
||||
),
|
||||
"feature_explainer": (
|
||||
f"Demonstrating features of {base_description}, "
|
||||
f"detailed explanation, pointing gestures, clear visual communication, "
|
||||
f"educational and informative, professional presentation"
|
||||
),
|
||||
"tutorial": (
|
||||
f"Tutorial presentation for {base_description}, "
|
||||
f"step-by-step explanation, instructional and clear, "
|
||||
f"friendly and approachable, educational setting"
|
||||
),
|
||||
"brand_message": (
|
||||
f"Brand message delivery for {base_description}, "
|
||||
f"authentic and compelling, brand storytelling, "
|
||||
f"emotional connection, professional brand representation"
|
||||
),
|
||||
}
|
||||
|
||||
prompt = explainer_prompts.get(explainer_type, base_description)
|
||||
|
||||
# Add brand context if available
|
||||
if brand_context:
|
||||
visual_identity = brand_context.get("visual_identity", {})
|
||||
if visual_identity.get("style_guidelines"):
|
||||
style = visual_identity["style_guidelines"].get("aesthetic", "")
|
||||
if style:
|
||||
prompt += f", {style} style"
|
||||
|
||||
# Add brand values if available
|
||||
if visual_identity.get("brand_values"):
|
||||
values = ", ".join(visual_identity["brand_values"][:2]) # First 2 values
|
||||
prompt += f", embodying {values}"
|
||||
|
||||
# Add additional context
|
||||
if additional_context:
|
||||
prompt += f", {additional_context}"
|
||||
|
||||
return prompt
|
||||
|
||||
def _generate_audio_from_script(
|
||||
self,
|
||||
script_text: str,
|
||||
user_id: str,
|
||||
output_dir: Path
|
||||
) -> bytes:
|
||||
"""
|
||||
Generate audio from script text using TTS.
|
||||
|
||||
Args:
|
||||
script_text: Text to convert to speech
|
||||
user_id: User ID for tracking
|
||||
output_dir: Directory to save temporary audio file
|
||||
|
||||
Returns:
|
||||
Audio bytes
|
||||
"""
|
||||
try:
|
||||
# Create temporary audio file
|
||||
audio_filename = f"avatar_audio_{uuid.uuid4().hex[:8]}.mp3"
|
||||
audio_path = output_dir / audio_filename
|
||||
|
||||
# Generate audio using gTTS (free, always available)
|
||||
# Note: For premium voices, we could integrate Minimax voice clone here
|
||||
success = self.audio_service._generate_audio_gtts(
|
||||
text=script_text,
|
||||
output_path=audio_path,
|
||||
lang="en",
|
||||
slow=False
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise RuntimeError("Failed to generate audio from script")
|
||||
|
||||
# Read audio bytes
|
||||
with open(audio_path, 'rb') as f:
|
||||
audio_bytes = f.read()
|
||||
|
||||
# Clean up temporary file
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"[Product Avatar] Generated audio from script: {len(audio_bytes)} bytes")
|
||||
return audio_bytes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Avatar] Error generating audio: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def generate_product_explainer(
|
||||
self,
|
||||
request: ProductAvatarRequest,
|
||||
user_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate product explainer video using InfiniteTalk.
|
||||
|
||||
Args:
|
||||
request: Product avatar request
|
||||
user_id: User ID for tracking
|
||||
|
||||
Returns:
|
||||
Explainer video result with video URL and metadata
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"[Product Avatar] Generating {request.explainer_type} explainer for product '{request.product_name}' "
|
||||
f"for user {user_id}"
|
||||
)
|
||||
|
||||
# Prepare audio
|
||||
audio_base64 = request.audio_base64
|
||||
if not audio_base64 and request.script_text:
|
||||
# Generate audio from script
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
temp_dir = base_dir / "temp_audio"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
audio_bytes = self._generate_audio_from_script(
|
||||
script_text=request.script_text,
|
||||
user_id=user_id,
|
||||
output_dir=temp_dir
|
||||
)
|
||||
|
||||
# Convert to base64
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode('utf-8')
|
||||
audio_base64 = f"data:audio/mpeg;base64,{audio_base64}"
|
||||
|
||||
if not audio_base64:
|
||||
raise ValueError("Either audio_base64 or script_text must be provided")
|
||||
|
||||
# Build avatar prompt
|
||||
avatar_prompt = request.prompt
|
||||
if not avatar_prompt:
|
||||
avatar_prompt = self._build_avatar_prompt(
|
||||
explainer_type=request.explainer_type,
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
brand_context=request.brand_context,
|
||||
additional_context=request.additional_context
|
||||
)
|
||||
|
||||
# Generate video using InfiniteTalk
|
||||
result = await self.infinitetalk_service.create_talking_avatar(
|
||||
image_base64=request.avatar_image_base64,
|
||||
audio_base64=audio_base64,
|
||||
resolution=request.resolution,
|
||||
prompt=avatar_prompt,
|
||||
mask_image_base64=request.mask_image_base64,
|
||||
seed=request.seed,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Extract video bytes and save to user directory
|
||||
video_bytes = result.get("video_bytes")
|
||||
if not video_bytes:
|
||||
raise ValueError("Avatar generation returned no video bytes")
|
||||
|
||||
# Save video file
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
output_dir = base_dir / "product_avatars"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create user-specific directory
|
||||
user_dir = output_dir / user_id
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate filename
|
||||
safe_product_name = "".join(c for c in request.product_name if c.isalnum() or c in (' ', '-', '_')).strip()[:30]
|
||||
filename = f"explainer_{safe_product_name}_{request.explainer_type}_{uuid.uuid4().hex[:8]}.mp4"
|
||||
filename = filename.replace(" ", "_").replace("/", "_").replace("\\", "_")
|
||||
|
||||
# Save file
|
||||
file_path = user_dir / filename
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(video_bytes)
|
||||
|
||||
# Check file size (500MB max)
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size > 500 * 1024 * 1024:
|
||||
os.remove(file_path)
|
||||
raise RuntimeError(f"Video file too large: {file_size / (1024*1024):.2f}MB (max 500MB)")
|
||||
|
||||
file_url = f"/api/product-marketing/avatars/{user_id}/{filename}"
|
||||
|
||||
# Add product-specific metadata
|
||||
result["product_name"] = request.product_name
|
||||
result["explainer_type"] = request.explainer_type
|
||||
result["source_module"] = "product_marketing"
|
||||
result["filename"] = filename
|
||||
result["file_path"] = str(file_path)
|
||||
result["file_url"] = file_url
|
||||
result["file_size"] = file_size
|
||||
result["duration"] = result.get("duration", 0.0)
|
||||
|
||||
logger.info(
|
||||
f"[Product Avatar] ✅ Product explainer video generated successfully: "
|
||||
f"cost=${result.get('cost', 0):.2f}, duration={result.get('duration', 0):.1f}s, "
|
||||
f"video_url={file_url}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Avatar] ❌ Error generating product explainer: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def create_product_overview(
|
||||
self,
|
||||
avatar_image_base64: str,
|
||||
script_text: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product overview explainer video."""
|
||||
request = ProductAvatarRequest(
|
||||
avatar_image_base64=avatar_image_base64,
|
||||
script_text=script_text,
|
||||
audio_base64=audio_base64,
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
explainer_type="product_overview",
|
||||
resolution=resolution,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.generate_product_explainer(request, user_id)
|
||||
|
||||
async def create_feature_explainer(
|
||||
self,
|
||||
avatar_image_base64: str,
|
||||
script_text: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product feature explainer video."""
|
||||
request = ProductAvatarRequest(
|
||||
avatar_image_base64=avatar_image_base64,
|
||||
script_text=script_text,
|
||||
audio_base64=audio_base64,
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
explainer_type="feature_explainer",
|
||||
resolution=resolution,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.generate_product_explainer(request, user_id)
|
||||
|
||||
async def create_tutorial(
|
||||
self,
|
||||
avatar_image_base64: str,
|
||||
script_text: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product tutorial video."""
|
||||
request = ProductAvatarRequest(
|
||||
avatar_image_base64=avatar_image_base64,
|
||||
script_text=script_text,
|
||||
audio_base64=audio_base64,
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
explainer_type="tutorial",
|
||||
resolution=resolution,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.generate_product_explainer(request, user_id)
|
||||
|
||||
async def create_brand_message(
|
||||
self,
|
||||
avatar_image_base64: str,
|
||||
script_text: str,
|
||||
product_name: str,
|
||||
product_description: Optional[str],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create brand message video."""
|
||||
request = ProductAvatarRequest(
|
||||
avatar_image_base64=avatar_image_base64,
|
||||
script_text=script_text,
|
||||
audio_base64=audio_base64,
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
explainer_type="brand_message",
|
||||
resolution=resolution,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.generate_product_explainer(request, user_id)
|
||||
312
backend/services/product_marketing/product_video_service.py
Normal file
312
backend/services/product_marketing/product_video_service.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Product Video Service
|
||||
Handles product demo video generation using WAN 2.5 Text-to-Video via main_video_generation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from loguru import logger
|
||||
from dataclasses import dataclass
|
||||
|
||||
from services.llm_providers.main_video_generation import ai_video_generate
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
logger = get_service_logger("product_marketing.video")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductVideoRequest:
|
||||
"""Request for product demo video generation."""
|
||||
product_name: str
|
||||
product_description: str
|
||||
video_type: str # "demo", "storytelling", "feature_highlight", "launch"
|
||||
resolution: str = "720p" # 480p, 720p, 1080p
|
||||
duration: int = 10 # 5 or 10 seconds
|
||||
audio_base64: Optional[str] = None
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
additional_context: Optional[str] = None
|
||||
negative_prompt: Optional[str] = None
|
||||
seed: Optional[int] = None
|
||||
|
||||
|
||||
class ProductVideoService:
|
||||
"""Service for product demo video generation using WAN 2.5 Text-to-Video."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Product Video Service."""
|
||||
logger.info("[Product Video Service] Initialized")
|
||||
|
||||
def _build_video_prompt(
|
||||
self,
|
||||
video_type: str,
|
||||
product_name: str,
|
||||
product_description: str,
|
||||
brand_context: Optional[Dict[str, Any]],
|
||||
additional_context: Optional[str]
|
||||
) -> str:
|
||||
"""
|
||||
Build video prompt based on video type and product context.
|
||||
|
||||
Args:
|
||||
video_type: Type of video (demo, storytelling, feature_highlight, launch)
|
||||
product_name: Product name
|
||||
product_description: Product description
|
||||
brand_context: Brand DNA context
|
||||
additional_context: Additional context
|
||||
|
||||
Returns:
|
||||
Video generation prompt
|
||||
"""
|
||||
base_description = f"{product_name}"
|
||||
if product_description:
|
||||
base_description += f": {product_description}"
|
||||
|
||||
# Video type-specific prompts
|
||||
video_prompts = {
|
||||
"demo": (
|
||||
f"{base_description} being demonstrated in use, showcasing key features and benefits, "
|
||||
f"professional product demonstration, dynamic camera movement, engaging presentation, "
|
||||
f"clear product visibility, modern and clean aesthetic"
|
||||
),
|
||||
"storytelling": (
|
||||
f"Story of {base_description}, narrative-driven product showcase, emotional connection, "
|
||||
f"cinematic storytelling, compelling visual narrative, professional cinematography, "
|
||||
f"engaging product story"
|
||||
),
|
||||
"feature_highlight": (
|
||||
f"{base_description} highlighting key features, close-up shots of important details, "
|
||||
f"feature-focused presentation, professional product photography, clear feature visibility, "
|
||||
f"modern and sleek aesthetic"
|
||||
),
|
||||
"launch": (
|
||||
f"{base_description} product launch reveal, exciting unveiling, dynamic presentation, "
|
||||
f"professional product showcase, launch event aesthetic, engaging and energetic, "
|
||||
f"modern and premium feel"
|
||||
),
|
||||
}
|
||||
|
||||
prompt = video_prompts.get(video_type, base_description)
|
||||
|
||||
# Add brand context if available
|
||||
if brand_context:
|
||||
visual_identity = brand_context.get("visual_identity", {})
|
||||
if visual_identity.get("color_palette"):
|
||||
colors = ", ".join(visual_identity["color_palette"][:3]) # First 3 colors
|
||||
prompt += f", {colors} color scheme"
|
||||
|
||||
if visual_identity.get("style_guidelines"):
|
||||
style = visual_identity["style_guidelines"].get("aesthetic", "")
|
||||
if style:
|
||||
prompt += f", {style} style"
|
||||
|
||||
# Add brand values if available
|
||||
if visual_identity.get("brand_values"):
|
||||
values = ", ".join(visual_identity["brand_values"][:2]) # First 2 values
|
||||
prompt += f", embodying {values}"
|
||||
|
||||
# Add additional context
|
||||
if additional_context:
|
||||
prompt += f", {additional_context}"
|
||||
|
||||
return prompt
|
||||
|
||||
async def generate_product_video(
|
||||
self,
|
||||
request: ProductVideoRequest,
|
||||
user_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate product demo video using WAN 2.5 Text-to-Video.
|
||||
|
||||
This method uses the unified ai_video_generate() entry point which handles:
|
||||
- Pre-flight validation
|
||||
- Usage tracking
|
||||
- Cost tracking
|
||||
- Error handling
|
||||
|
||||
Args:
|
||||
request: Product video request
|
||||
user_id: User ID for tracking
|
||||
|
||||
Returns:
|
||||
Video generation result with video URL and metadata
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"[Product Video] Generating {request.video_type} video for product '{request.product_name}' "
|
||||
f"for user {user_id}"
|
||||
)
|
||||
|
||||
# Build video prompt
|
||||
video_prompt = self._build_video_prompt(
|
||||
video_type=request.video_type,
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
brand_context=request.brand_context,
|
||||
additional_context=request.additional_context
|
||||
)
|
||||
|
||||
# Build negative prompt (default to avoid common issues)
|
||||
negative_prompt = request.negative_prompt or (
|
||||
"blurry, low quality, distorted, deformed, ugly, bad anatomy, "
|
||||
"watermark, text overlay, logo, signature"
|
||||
)
|
||||
|
||||
# Generate video using unified entry point
|
||||
# This handles pre-flight validation, usage tracking, and cost tracking automatically
|
||||
result = await ai_video_generate(
|
||||
prompt=video_prompt,
|
||||
operation_type="text-to-video",
|
||||
provider="wavespeed",
|
||||
user_id=user_id,
|
||||
model="alibaba/wan-2.5/text-to-video", # WAN 2.5 Text-to-Video
|
||||
duration=request.duration,
|
||||
resolution=request.resolution,
|
||||
audio_base64=request.audio_base64,
|
||||
negative_prompt=negative_prompt,
|
||||
seed=request.seed,
|
||||
enable_prompt_expansion=True, # Enable prompt optimization
|
||||
)
|
||||
|
||||
# Extract video bytes and save to user directory
|
||||
video_bytes = result.get("video_bytes")
|
||||
if not video_bytes:
|
||||
raise ValueError("Video generation returned no video bytes")
|
||||
|
||||
# Save video file (similar to Transform Studio)
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import os
|
||||
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
output_dir = base_dir / "product_videos"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create user-specific directory
|
||||
user_dir = output_dir / user_id
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate filename (sanitize to avoid issues)
|
||||
safe_product_name = "".join(c for c in request.product_name if c.isalnum() or c in (' ', '-', '_')).strip()[:30]
|
||||
filename = f"product_{safe_product_name}_{request.video_type}_{uuid.uuid4().hex[:8]}.mp4"
|
||||
filename = filename.replace(" ", "_").replace("/", "_").replace("\\", "_")
|
||||
|
||||
# Save file
|
||||
file_path = user_dir / filename
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(video_bytes)
|
||||
|
||||
# Check file size (500MB max)
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size > 500 * 1024 * 1024:
|
||||
os.remove(file_path)
|
||||
raise RuntimeError(f"Video file too large: {file_size / (1024*1024):.2f}MB (max 500MB)")
|
||||
|
||||
file_url = f"/api/product-marketing/videos/{user_id}/{filename}"
|
||||
|
||||
# Add product-specific metadata
|
||||
result["product_name"] = request.product_name
|
||||
result["video_type"] = request.video_type
|
||||
result["source_module"] = "product_marketing"
|
||||
result["filename"] = filename
|
||||
result["file_path"] = str(file_path)
|
||||
result["file_url"] = file_url
|
||||
result["file_size"] = len(video_bytes)
|
||||
|
||||
logger.info(
|
||||
f"[Product Video] ✅ Product video generated successfully: "
|
||||
f"cost=${result.get('cost', 0):.2f}, video_url={file_url}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Video] ❌ Error generating product video: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def create_product_demo(
|
||||
self,
|
||||
product_name: str,
|
||||
product_description: str,
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
duration: int = 10,
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product demo video (product in use, demonstrating features)."""
|
||||
request = ProductVideoRequest(
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
video_type="demo",
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
audio_base64=audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.generate_product_video(request, user_id)
|
||||
|
||||
async def create_product_storytelling(
|
||||
self,
|
||||
product_name: str,
|
||||
product_description: str,
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
duration: int = 10,
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product storytelling video (narrative-driven product showcase)."""
|
||||
request = ProductVideoRequest(
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
video_type="storytelling",
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
audio_base64=audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.generate_product_video(request, user_id)
|
||||
|
||||
async def create_product_feature_highlight(
|
||||
self,
|
||||
product_name: str,
|
||||
product_description: str,
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
duration: int = 10,
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product feature highlight video (close-up shots of key features)."""
|
||||
request = ProductVideoRequest(
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
video_type="feature_highlight",
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
audio_base64=audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.generate_product_video(request, user_id)
|
||||
|
||||
async def create_product_launch(
|
||||
self,
|
||||
product_name: str,
|
||||
product_description: str,
|
||||
user_id: str,
|
||||
resolution: str = "1080p", # Higher quality for launch
|
||||
duration: int = 10,
|
||||
audio_base64: Optional[str] = None,
|
||||
brand_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create product launch video (exciting unveiling, launch event aesthetic)."""
|
||||
request = ProductVideoRequest(
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
video_type="launch",
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
audio_base64=audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
return await self.generate_product_video(request, user_id)
|
||||
Reference in New Issue
Block a user