AI Researcher and Video Studio implementation complete
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Literal
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -16,6 +16,7 @@ from services.image_studio import (
|
||||
TransformImageToVideoRequest,
|
||||
TalkingAvatarRequest,
|
||||
)
|
||||
from services.image_studio.face_swap_service import FaceSwapStudioRequest
|
||||
from services.image_studio.upscale_service import UpscaleStudioRequest
|
||||
from services.image_studio.templates import Platform, TemplateCategory
|
||||
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
||||
@@ -97,6 +98,27 @@ class EditImageRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class EditModelsResponse(BaseModel):
|
||||
"""Response model for available editing models."""
|
||||
models: List[Dict[str, Any]]
|
||||
total: int
|
||||
|
||||
|
||||
class EditModelRecommendationRequest(BaseModel):
|
||||
"""Request model for model recommendations."""
|
||||
operation: str
|
||||
image_resolution: Optional[Dict[str, int]] = None
|
||||
user_tier: Optional[str] = None
|
||||
preferences: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class EditModelRecommendationResponse(BaseModel):
|
||||
"""Response model for model recommendations."""
|
||||
recommended_model: str
|
||||
reason: str
|
||||
alternatives: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class EditImageResponse(BaseModel):
|
||||
success: bool
|
||||
operation: str
|
||||
@@ -512,6 +534,173 @@ async def get_edit_operations(
|
||||
raise HTTPException(status_code=500, detail="Failed to load edit operations")
|
||||
|
||||
|
||||
@router.get("/edit/models", response_model=EditModelsResponse, summary="List available editing models")
|
||||
async def get_edit_models(
|
||||
operation: Optional[str] = None,
|
||||
tier: Optional[str] = None,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Get available WaveSpeed editing models with metadata.
|
||||
|
||||
Query Parameters:
|
||||
- operation: Filter by operation type (e.g., "general_edit")
|
||||
- tier: Filter by tier ("budget", "mid", "premium")
|
||||
"""
|
||||
try:
|
||||
result = studio_manager.get_edit_models(operation=operation, tier=tier)
|
||||
return EditModelsResponse(**result)
|
||||
except Exception as e:
|
||||
logger.error(f"[Edit Models] ❌ Error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to load editing models")
|
||||
|
||||
|
||||
@router.post("/edit/recommend", response_model=EditModelRecommendationResponse, summary="Get model recommendation")
|
||||
async def recommend_edit_model(
|
||||
request: EditModelRecommendationRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Get recommended editing model based on operation, image resolution, and user preferences.
|
||||
|
||||
Auto-detects best model when user doesn't specify one.
|
||||
"""
|
||||
try:
|
||||
# Get user tier from current_user if available
|
||||
user_tier = request.user_tier
|
||||
if not user_tier and current_user:
|
||||
# Try to extract from user data (adjust based on your user model)
|
||||
user_tier = current_user.get("tier") or current_user.get("subscription_tier")
|
||||
|
||||
result = studio_manager.recommend_edit_model(
|
||||
operation=request.operation,
|
||||
image_resolution=request.image_resolution,
|
||||
user_tier=user_tier,
|
||||
preferences=request.preferences,
|
||||
)
|
||||
return EditModelRecommendationResponse(**result)
|
||||
except Exception as e:
|
||||
logger.error(f"[Edit Recommend] ❌ Error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get recommendation: {e}")
|
||||
|
||||
|
||||
# ====================
|
||||
# FACE SWAP STUDIO ENDPOINTS
|
||||
# ====================
|
||||
|
||||
class FaceSwapRequest(BaseModel):
|
||||
base_image_base64: str
|
||||
face_image_base64: str
|
||||
model: Optional[str] = None
|
||||
target_face_index: Optional[int] = None
|
||||
target_gender: Optional[str] = None
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class FaceSwapResponse(BaseModel):
|
||||
success: bool
|
||||
image_base64: str
|
||||
width: int
|
||||
height: int
|
||||
provider: str
|
||||
model: str
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
class FaceSwapModelsResponse(BaseModel):
|
||||
"""Response model for available face swap models."""
|
||||
models: List[Dict[str, Any]]
|
||||
total: int
|
||||
|
||||
|
||||
class FaceSwapModelRecommendationRequest(BaseModel):
|
||||
"""Request model for face swap model recommendations."""
|
||||
base_image_resolution: Optional[Dict[str, int]] = None
|
||||
face_image_resolution: Optional[Dict[str, int]] = None
|
||||
user_tier: Optional[str] = None
|
||||
preferences: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class FaceSwapModelRecommendationResponse(BaseModel):
|
||||
"""Response model for face swap model recommendations."""
|
||||
recommended_model: str
|
||||
reason: str
|
||||
alternatives: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@router.post("/face-swap/process", response_model=FaceSwapResponse, summary="Process Face Swap")
|
||||
async def process_face_swap(
|
||||
request: FaceSwapRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Process face swap request with auto-detection and model selection."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "face swap")
|
||||
face_swap_request = FaceSwapStudioRequest(
|
||||
base_image_base64=request.base_image_base64,
|
||||
face_image_base64=request.face_image_base64,
|
||||
model=request.model,
|
||||
target_face_index=request.target_face_index,
|
||||
target_gender=request.target_gender,
|
||||
options=request.options,
|
||||
)
|
||||
result = await studio_manager.face_swap(face_swap_request, user_id=user_id)
|
||||
return FaceSwapResponse(**result)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Face Swap] ❌ Error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Face swap failed: {e}")
|
||||
|
||||
|
||||
@router.get("/face-swap/models", response_model=FaceSwapModelsResponse, summary="List available face swap models")
|
||||
async def get_face_swap_models(
|
||||
tier: Optional[str] = None,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Get available WaveSpeed face swap models with metadata.
|
||||
|
||||
Query Parameters:
|
||||
- tier: Filter by tier ("budget", "mid", "premium")
|
||||
"""
|
||||
try:
|
||||
result = studio_manager.get_face_swap_models(tier=tier)
|
||||
return FaceSwapModelsResponse(**result)
|
||||
except Exception as e:
|
||||
logger.error(f"[Face Swap Models] ❌ Error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to load face swap models")
|
||||
|
||||
|
||||
@router.post("/face-swap/recommend", response_model=FaceSwapModelRecommendationResponse, summary="Get face swap model recommendation")
|
||||
async def recommend_face_swap_model(
|
||||
request: FaceSwapModelRecommendationRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Get recommended face swap model based on image resolutions and user preferences.
|
||||
|
||||
Auto-detects best model when user doesn't specify one.
|
||||
"""
|
||||
try:
|
||||
# Get user tier from current_user if available
|
||||
user_tier = request.user_tier
|
||||
if not user_tier and current_user:
|
||||
user_tier = current_user.get("tier") or current_user.get("subscription_tier")
|
||||
|
||||
result = studio_manager.recommend_face_swap_model(
|
||||
base_image_resolution=request.base_image_resolution,
|
||||
face_image_resolution=request.face_image_resolution,
|
||||
user_tier=user_tier,
|
||||
preferences=request.preferences,
|
||||
)
|
||||
return FaceSwapModelRecommendationResponse(**result)
|
||||
except Exception as e:
|
||||
logger.error(f"[Face Swap Recommend] ❌ Error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get recommendation: {e}")
|
||||
|
||||
|
||||
# ====================
|
||||
# UPSCALE STUDIO ENDPOINTS
|
||||
# ====================
|
||||
@@ -1009,6 +1198,403 @@ async def serve_transform_video(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# COMPRESSION STUDIO ENDPOINTS
|
||||
# ====================
|
||||
|
||||
class CompressImageRequest(BaseModel):
|
||||
"""Request payload for image compression."""
|
||||
image_base64: str = Field(..., description="Image in base64 or data URL format")
|
||||
quality: int = Field(85, ge=1, le=100, description="Compression quality (1-100)")
|
||||
format: str = Field("jpeg", description="Output format: jpeg, png, webp")
|
||||
target_size_kb: Optional[int] = Field(None, ge=10, description="Target file size in KB")
|
||||
strip_metadata: bool = Field(True, description="Remove EXIF metadata")
|
||||
progressive: bool = Field(True, description="Progressive JPEG encoding")
|
||||
optimize: bool = Field(True, description="Optimize encoding")
|
||||
|
||||
|
||||
class CompressImageResponse(BaseModel):
|
||||
success: bool
|
||||
image_base64: str
|
||||
original_size_kb: float
|
||||
compressed_size_kb: float
|
||||
compression_ratio: float
|
||||
format: str
|
||||
width: int
|
||||
height: int
|
||||
quality_used: int
|
||||
metadata_stripped: bool
|
||||
|
||||
|
||||
class CompressBatchRequest(BaseModel):
|
||||
"""Request payload for batch compression."""
|
||||
images: List[CompressImageRequest] = Field(..., description="List of images to compress")
|
||||
|
||||
|
||||
class CompressBatchResponse(BaseModel):
|
||||
success: bool
|
||||
results: List[CompressImageResponse]
|
||||
total_images: int
|
||||
successful: int
|
||||
failed: int
|
||||
|
||||
|
||||
class CompressionEstimateRequest(BaseModel):
|
||||
"""Request for compression estimation."""
|
||||
image_base64: str = Field(..., description="Image in base64 or data URL format")
|
||||
format: str = Field("jpeg", description="Output format")
|
||||
quality: int = Field(85, ge=1, le=100, description="Quality level")
|
||||
|
||||
|
||||
class CompressionEstimateResponse(BaseModel):
|
||||
original_size_kb: float
|
||||
estimated_size_kb: float
|
||||
estimated_reduction_percent: float
|
||||
width: int
|
||||
height: int
|
||||
format: str
|
||||
|
||||
|
||||
class CompressionFormatsResponse(BaseModel):
|
||||
formats: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class CompressionPresetsResponse(BaseModel):
|
||||
presets: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@router.post("/compress", response_model=CompressImageResponse, summary="Compress an image")
|
||||
async def compress_image(
|
||||
request: CompressImageRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Compress an image with specified quality and format settings.
|
||||
|
||||
Features:
|
||||
- Quality control (1-100)
|
||||
- Format conversion (JPEG, PNG, WebP)
|
||||
- Target size compression
|
||||
- Metadata stripping
|
||||
- Progressive JPEG support
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "image compression")
|
||||
logger.info(f"[Compression] Request from user {user_id}: format={request.format}, quality={request.quality}")
|
||||
|
||||
from services.image_studio.compression_service import CompressionRequest as ServiceRequest
|
||||
|
||||
compression_request = ServiceRequest(
|
||||
image_base64=request.image_base64,
|
||||
quality=request.quality,
|
||||
format=request.format,
|
||||
target_size_kb=request.target_size_kb,
|
||||
strip_metadata=request.strip_metadata,
|
||||
progressive=request.progressive,
|
||||
optimize=request.optimize,
|
||||
)
|
||||
|
||||
result = await studio_manager.compress_image(compression_request, user_id=user_id)
|
||||
|
||||
return CompressImageResponse(
|
||||
success=result.success,
|
||||
image_base64=result.image_base64,
|
||||
original_size_kb=result.original_size_kb,
|
||||
compressed_size_kb=result.compressed_size_kb,
|
||||
compression_ratio=result.compression_ratio,
|
||||
format=result.format,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
quality_used=result.quality_used,
|
||||
metadata_stripped=result.metadata_stripped,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Compression] ❌ Error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Image compression failed: {e}")
|
||||
|
||||
|
||||
@router.post("/compress/batch", response_model=CompressBatchResponse, summary="Compress multiple images")
|
||||
async def compress_batch(
|
||||
request: CompressBatchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Compress multiple images with the same or individual settings."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "batch compression")
|
||||
logger.info(f"[Compression] Batch request from user {user_id}: {len(request.images)} images")
|
||||
|
||||
from services.image_studio.compression_service import CompressionRequest as ServiceRequest
|
||||
|
||||
compression_requests = [
|
||||
ServiceRequest(
|
||||
image_base64=img.image_base64,
|
||||
quality=img.quality,
|
||||
format=img.format,
|
||||
target_size_kb=img.target_size_kb,
|
||||
strip_metadata=img.strip_metadata,
|
||||
progressive=img.progressive,
|
||||
optimize=img.optimize,
|
||||
)
|
||||
for img in request.images
|
||||
]
|
||||
|
||||
results = await studio_manager.compress_batch(compression_requests, user_id=user_id)
|
||||
|
||||
successful = sum(1 for r in results if r.success)
|
||||
failed = len(results) - successful
|
||||
|
||||
return CompressBatchResponse(
|
||||
success=failed == 0,
|
||||
results=[
|
||||
CompressImageResponse(
|
||||
success=r.success,
|
||||
image_base64=r.image_base64,
|
||||
original_size_kb=r.original_size_kb,
|
||||
compressed_size_kb=r.compressed_size_kb,
|
||||
compression_ratio=r.compression_ratio,
|
||||
format=r.format,
|
||||
width=r.width,
|
||||
height=r.height,
|
||||
quality_used=r.quality_used,
|
||||
metadata_stripped=r.metadata_stripped,
|
||||
)
|
||||
for r in results
|
||||
],
|
||||
total_images=len(results),
|
||||
successful=successful,
|
||||
failed=failed,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Compression] ❌ Batch error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Batch compression failed: {e}")
|
||||
|
||||
|
||||
@router.post("/compress/estimate", response_model=CompressionEstimateResponse, summary="Estimate compression results")
|
||||
async def estimate_compression(
|
||||
request: CompressionEstimateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Estimate compression results without actually compressing the image."""
|
||||
try:
|
||||
result = await studio_manager.estimate_compression(
|
||||
request.image_base64,
|
||||
request.format,
|
||||
request.quality,
|
||||
)
|
||||
return CompressionEstimateResponse(**result)
|
||||
except Exception as e:
|
||||
logger.error(f"[Compression] ❌ Estimate error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Compression estimation failed: {e}")
|
||||
|
||||
|
||||
@router.get("/compress/formats", response_model=CompressionFormatsResponse, summary="Get supported compression formats")
|
||||
async def get_compression_formats(
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Get list of supported compression formats with their capabilities."""
|
||||
formats = studio_manager.get_compression_formats()
|
||||
return CompressionFormatsResponse(formats=formats)
|
||||
|
||||
|
||||
@router.get("/compress/presets", response_model=CompressionPresetsResponse, summary="Get compression presets")
|
||||
async def get_compression_presets(
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Get predefined compression presets for common use cases."""
|
||||
presets = studio_manager.get_compression_presets()
|
||||
return CompressionPresetsResponse(presets=presets)
|
||||
|
||||
|
||||
# ====================
|
||||
# FORMAT CONVERTER ENDPOINTS
|
||||
# ====================
|
||||
|
||||
class ConvertFormatRequest(BaseModel):
|
||||
"""Request payload for format conversion."""
|
||||
image_base64: str = Field(..., description="Image in base64 or data URL format")
|
||||
target_format: str = Field(..., description="Target format: png, jpeg, jpg, webp, gif, bmp, tiff")
|
||||
preserve_transparency: bool = Field(True, description="Preserve transparency when possible")
|
||||
quality: Optional[int] = Field(None, ge=1, le=100, description="Quality for lossy formats (1-100)")
|
||||
color_space: Optional[str] = Field(None, description="Color space: sRGB, Adobe RGB")
|
||||
strip_metadata: bool = Field(False, description="Remove EXIF metadata")
|
||||
optimize: bool = Field(True, description="Optimize encoding")
|
||||
progressive: bool = Field(True, description="Progressive JPEG encoding")
|
||||
|
||||
|
||||
class ConvertFormatResponse(BaseModel):
|
||||
success: bool
|
||||
image_base64: str
|
||||
original_format: str
|
||||
target_format: str
|
||||
original_size_kb: float
|
||||
converted_size_kb: float
|
||||
width: int
|
||||
height: int
|
||||
transparency_preserved: bool
|
||||
metadata_preserved: bool
|
||||
color_space: Optional[str] = None
|
||||
|
||||
|
||||
class ConvertFormatBatchRequest(BaseModel):
|
||||
"""Request payload for batch format conversion."""
|
||||
images: List[ConvertFormatRequest] = Field(..., description="List of images to convert")
|
||||
|
||||
|
||||
class ConvertFormatBatchResponse(BaseModel):
|
||||
success: bool
|
||||
results: List[ConvertFormatResponse]
|
||||
total_images: int
|
||||
successful: int
|
||||
failed: int
|
||||
|
||||
|
||||
class SupportedFormatsResponse(BaseModel):
|
||||
formats: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class FormatRecommendationsResponse(BaseModel):
|
||||
recommendations: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@router.post("/convert-format", response_model=ConvertFormatResponse, summary="Convert image format")
|
||||
async def convert_format(
|
||||
request: ConvertFormatRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Convert an image to a different format.
|
||||
|
||||
Features:
|
||||
- Multi-format support (PNG, JPEG, WebP, GIF, BMP, TIFF)
|
||||
- Transparency preservation
|
||||
- Color space conversion
|
||||
- Metadata handling
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "format conversion")
|
||||
logger.info(f"[Format Converter] Request from user {user_id}: {request.target_format}")
|
||||
|
||||
from services.image_studio.format_converter_service import FormatConversionRequest as ServiceRequest
|
||||
|
||||
conversion_request = ServiceRequest(
|
||||
image_base64=request.image_base64,
|
||||
target_format=request.target_format,
|
||||
preserve_transparency=request.preserve_transparency,
|
||||
quality=request.quality,
|
||||
color_space=request.color_space,
|
||||
strip_metadata=request.strip_metadata,
|
||||
optimize=request.optimize,
|
||||
progressive=request.progressive,
|
||||
)
|
||||
|
||||
result = await studio_manager.convert_format(conversion_request, user_id=user_id)
|
||||
|
||||
return ConvertFormatResponse(
|
||||
success=result.success,
|
||||
image_base64=result.image_base64,
|
||||
original_format=result.original_format,
|
||||
target_format=result.target_format,
|
||||
original_size_kb=result.original_size_kb,
|
||||
converted_size_kb=result.converted_size_kb,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
transparency_preserved=result.transparency_preserved,
|
||||
metadata_preserved=result.metadata_preserved,
|
||||
color_space=result.color_space,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Format Converter] ❌ Error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Format conversion failed: {e}")
|
||||
|
||||
|
||||
@router.post("/convert-format/batch", response_model=ConvertFormatBatchResponse, summary="Convert multiple images")
|
||||
async def convert_format_batch(
|
||||
request: ConvertFormatBatchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Convert multiple images to different formats."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "batch format conversion")
|
||||
logger.info(f"[Format Converter] Batch request from user {user_id}: {len(request.images)} images")
|
||||
|
||||
from services.image_studio.format_converter_service import FormatConversionRequest as ServiceRequest
|
||||
|
||||
conversion_requests = [
|
||||
ServiceRequest(
|
||||
image_base64=img.image_base64,
|
||||
target_format=img.target_format,
|
||||
preserve_transparency=img.preserve_transparency,
|
||||
quality=img.quality,
|
||||
color_space=img.color_space,
|
||||
strip_metadata=img.strip_metadata,
|
||||
optimize=img.optimize,
|
||||
progressive=img.progressive,
|
||||
)
|
||||
for img in request.images
|
||||
]
|
||||
|
||||
results = await studio_manager.convert_format_batch(conversion_requests, user_id=user_id)
|
||||
|
||||
successful = sum(1 for r in results if r.success)
|
||||
failed = len(results) - successful
|
||||
|
||||
return ConvertFormatBatchResponse(
|
||||
success=failed == 0,
|
||||
results=[
|
||||
ConvertFormatResponse(
|
||||
success=r.success,
|
||||
image_base64=r.image_base64,
|
||||
original_format=r.original_format,
|
||||
target_format=r.target_format,
|
||||
original_size_kb=r.original_size_kb,
|
||||
converted_size_kb=r.converted_size_kb,
|
||||
width=r.width,
|
||||
height=r.height,
|
||||
transparency_preserved=r.transparency_preserved,
|
||||
metadata_preserved=r.metadata_preserved,
|
||||
color_space=r.color_space,
|
||||
)
|
||||
for r in results
|
||||
],
|
||||
total_images=len(results),
|
||||
successful=successful,
|
||||
failed=failed,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Format Converter] ❌ Batch error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Batch format conversion failed: {e}")
|
||||
|
||||
|
||||
@router.get("/convert-format/supported", response_model=SupportedFormatsResponse, summary="Get supported formats")
|
||||
async def get_supported_formats(
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Get list of supported conversion formats with their capabilities."""
|
||||
formats = studio_manager.get_supported_formats()
|
||||
return SupportedFormatsResponse(formats=formats)
|
||||
|
||||
|
||||
@router.get("/convert-format/recommendations", response_model=FormatRecommendationsResponse, summary="Get format recommendations")
|
||||
async def get_format_recommendations(
|
||||
source_format: str = Query(..., description="Source format"),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Get format recommendations based on source format."""
|
||||
recommendations = studio_manager.get_format_recommendations(source_format)
|
||||
return FormatRecommendationsResponse(recommendations=recommendations)
|
||||
|
||||
|
||||
# ====================
|
||||
# HEALTH CHECK
|
||||
# ====================
|
||||
@@ -1028,6 +1614,7 @@ async def health_check():
|
||||
"create_studio": "available",
|
||||
"templates": "available",
|
||||
"providers": "available",
|
||||
"compression": "available",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ from services.product_marketing import (
|
||||
BrandDNASyncService,
|
||||
AssetAuditService,
|
||||
ChannelPackService,
|
||||
ProductAnimationService,
|
||||
ProductAnimationRequest,
|
||||
ProductVideoService,
|
||||
ProductVideoRequest,
|
||||
ProductAvatarService,
|
||||
ProductAvatarRequest,
|
||||
)
|
||||
from services.product_marketing.campaign_storage import CampaignStorageService
|
||||
from services.product_marketing.product_image_service import ProductImageService, ProductImageRequest
|
||||
@@ -268,6 +274,7 @@ async def generate_asset(
|
||||
- Applies specialized marketing prompts
|
||||
- Automatically tracks assets in Asset Library
|
||||
- Validates subscription limits
|
||||
- Updates campaign status after generation
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "asset generation")
|
||||
@@ -279,6 +286,51 @@ async def generate_asset(
|
||||
product_context=request.product_context,
|
||||
)
|
||||
|
||||
# Update campaign status if asset was generated successfully
|
||||
if result.get('success'):
|
||||
campaign_id = request.asset_proposal.get('campaign_id')
|
||||
if not campaign_id:
|
||||
# Try to extract from asset_id
|
||||
asset_id = request.asset_proposal.get('asset_id', '')
|
||||
if asset_id and '_' in asset_id:
|
||||
parts = asset_id.split('_')
|
||||
phase_indicators = ['teaser', 'launch', 'nurture', 'prelaunch', 'postlaunch']
|
||||
for i, part in enumerate(parts):
|
||||
if part.lower() in phase_indicators and i > 0:
|
||||
campaign_id = '_'.join(parts[:i])
|
||||
break
|
||||
|
||||
if campaign_id:
|
||||
try:
|
||||
campaign_storage = get_campaign_storage()
|
||||
campaign = campaign_storage.get_campaign(user_id, campaign_id)
|
||||
if campaign:
|
||||
# Update proposal status to 'generating' or 'ready'
|
||||
asset_node_id = request.asset_proposal.get('asset_id', '')
|
||||
if asset_node_id:
|
||||
from models.product_marketing_models import CampaignProposal
|
||||
from services.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
proposal = db.query(CampaignProposal).filter(
|
||||
CampaignProposal.campaign_id == campaign_id,
|
||||
CampaignProposal.asset_node_id == asset_node_id,
|
||||
CampaignProposal.user_id == user_id
|
||||
).first()
|
||||
if proposal:
|
||||
proposal.status = 'ready'
|
||||
db.commit()
|
||||
logger.info(f"[Product Marketing] ✅ Updated proposal status for {asset_node_id}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Check if all assets are ready and update campaign status
|
||||
# (This could be enhanced to check all proposals)
|
||||
logger.info(f"[Product Marketing] ✅ Asset generated for campaign {campaign_id}")
|
||||
except Exception as update_error:
|
||||
logger.warning(f"[Product Marketing] ⚠️ Could not update campaign status: {str(update_error)}")
|
||||
# Don't fail the request if status update fails
|
||||
|
||||
logger.info(f"[Product Marketing] ✅ Asset generated successfully")
|
||||
return result
|
||||
|
||||
@@ -617,6 +669,474 @@ async def serve_product_image(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# PRODUCT ANIMATION ENDPOINTS
|
||||
# ====================
|
||||
|
||||
class ProductAnimationRequestModel(BaseModel):
|
||||
"""Request for product animation."""
|
||||
product_image_base64: str = Field(..., description="Base64 encoded product image")
|
||||
animation_type: str = Field(..., description="Animation type: reveal, rotation, demo, lifestyle")
|
||||
product_name: str = Field(..., description="Product name")
|
||||
product_description: Optional[str] = Field(None, description="Product description")
|
||||
resolution: str = Field(default="720p", description="Video resolution: 480p, 720p, 1080p")
|
||||
duration: int = Field(default=5, description="Video duration: 5 or 10 seconds")
|
||||
audio_base64: Optional[str] = Field(None, description="Optional audio for synchronization")
|
||||
additional_context: Optional[str] = Field(None, description="Additional context for animation")
|
||||
|
||||
|
||||
def get_product_animation_service() -> ProductAnimationService:
|
||||
"""Get Product Animation Service instance."""
|
||||
return ProductAnimationService()
|
||||
|
||||
|
||||
@router.post("/products/animate", summary="Animate Product Image")
|
||||
async def animate_product(
|
||||
request: ProductAnimationRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
animation_service: ProductAnimationService = Depends(get_product_animation_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Animate a product image into a video.
|
||||
|
||||
This endpoint:
|
||||
- Uses WAN 2.5 Image-to-Video via Transform Studio
|
||||
- Supports multiple animation types (reveal, rotation, demo, lifestyle)
|
||||
- Applies brand DNA for consistent styling
|
||||
- Returns video URL and metadata
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product animation")
|
||||
logger.info(f"[Product Marketing] Animating product '{request.product_name}' with type '{request.animation_type}'")
|
||||
|
||||
# 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)}")
|
||||
|
||||
# Create animation request
|
||||
animation_request = ProductAnimationRequest(
|
||||
product_image_base64=request.product_image_base64,
|
||||
animation_type=request.animation_type,
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration,
|
||||
audio_base64=request.audio_base64,
|
||||
brand_context=brand_context,
|
||||
additional_context=request.additional_context,
|
||||
)
|
||||
|
||||
# Generate animation
|
||||
result = await animation_service.animate_product(animation_request, user_id)
|
||||
|
||||
logger.info(f"[Product Marketing] ✅ Product animation completed: cost=${result.get('cost', 0):.2f}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product_name": result.get("product_name"),
|
||||
"animation_type": result.get("animation_type"),
|
||||
"video_url": result.get("video_url"),
|
||||
"video_filename": result.get("filename"),
|
||||
"cost": result.get("cost", 0.0),
|
||||
"resolution": request.resolution,
|
||||
"duration": request.duration,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error animating product: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Product animation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/products/animate/reveal", summary="Create Product Reveal Animation")
|
||||
async def create_product_reveal(
|
||||
request: ProductAnimationRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
animation_service: ProductAnimationService = Depends(get_product_animation_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Create product reveal animation (elegant product unveiling)."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product reveal animation")
|
||||
|
||||
# Get brand DNA
|
||||
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:
|
||||
pass
|
||||
|
||||
result = await animation_service.create_product_reveal(
|
||||
product_image_base64=request.product_image_base64,
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
user_id=user_id,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration,
|
||||
brand_context=brand_context
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"animation_type": "reveal",
|
||||
"video_url": result.get("video_url"),
|
||||
"cost": result.get("cost", 0.0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error creating reveal: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/products/animate/rotation", summary="Create Product Rotation Animation")
|
||||
async def create_product_rotation(
|
||||
request: ProductAnimationRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
animation_service: ProductAnimationService = Depends(get_product_animation_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Create 360° product rotation animation."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product rotation animation")
|
||||
|
||||
# Get brand DNA
|
||||
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:
|
||||
pass
|
||||
|
||||
result = await animation_service.create_product_rotation(
|
||||
product_image_base64=request.product_image_base64,
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
user_id=user_id,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration or 10, # Default 10s for rotation
|
||||
brand_context=brand_context
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"animation_type": "rotation",
|
||||
"video_url": result.get("video_url"),
|
||||
"cost": result.get("cost", 0.0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error creating rotation: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/products/animate/demo", summary="Create Product Demo Animation")
|
||||
async def create_product_demo_animation(
|
||||
request: ProductAnimationRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
animation_service: ProductAnimationService = Depends(get_product_animation_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Create product demo animation (image-to-video: product in use, demonstrating features)."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product demo animation")
|
||||
|
||||
# Get brand DNA
|
||||
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:
|
||||
pass
|
||||
|
||||
result = await animation_service.create_product_demo(
|
||||
product_image_base64=request.product_image_base64,
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
user_id=user_id,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration or 10, # Default 10s for demo
|
||||
audio_base64=request.audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"animation_type": "demo",
|
||||
"video_subtype": "animation", # Image-to-video
|
||||
"video_url": result.get("video_url"),
|
||||
"cost": result.get("cost", 0.0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error creating demo animation: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# PRODUCT VIDEO ENDPOINTS (Text-to-Video)
|
||||
# ====================
|
||||
|
||||
class ProductVideoRequestModel(BaseModel):
|
||||
"""Request for product demo video (text-to-video)."""
|
||||
product_name: str = Field(..., description="Product name")
|
||||
product_description: str = Field(..., description="Product description")
|
||||
video_type: str = Field(default="demo", description="Video type: demo, storytelling, feature_highlight, launch")
|
||||
resolution: str = Field(default="720p", description="Video resolution: 480p, 720p, 1080p")
|
||||
duration: int = Field(default=10, description="Video duration: 5 or 10 seconds")
|
||||
audio_base64: Optional[str] = Field(None, description="Optional audio for synchronization")
|
||||
additional_context: Optional[str] = Field(None, description="Additional context for video")
|
||||
|
||||
|
||||
def get_product_video_service() -> ProductVideoService:
|
||||
"""Get Product Video Service instance."""
|
||||
return ProductVideoService()
|
||||
|
||||
|
||||
@router.post("/products/video/demo", summary="Create Product Demo Video (Text-to-Video)")
|
||||
async def create_product_demo_video(
|
||||
request: ProductVideoRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
video_service: ProductVideoService = Depends(get_product_video_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Create product demo video using WAN 2.5 Text-to-Video.
|
||||
|
||||
This endpoint:
|
||||
- Uses WAN 2.5 Text-to-Video via main_video_generation
|
||||
- Generates video from product description (no image required)
|
||||
- Applies brand DNA for consistent styling
|
||||
- Returns video URL and metadata
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product demo video")
|
||||
logger.info(f"[Product Marketing] Creating {request.video_type} video for product '{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)}")
|
||||
|
||||
# Create video request
|
||||
video_request = ProductVideoRequest(
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
video_type=request.video_type,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration,
|
||||
audio_base64=request.audio_base64,
|
||||
brand_context=brand_context,
|
||||
additional_context=request.additional_context,
|
||||
)
|
||||
|
||||
# Generate video using unified ai_video_generate()
|
||||
result = await video_service.generate_product_video(video_request, user_id)
|
||||
|
||||
logger.info(f"[Product Marketing] ✅ Product demo video completed: cost=${result.get('cost', 0):.2f}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product_name": result.get("product_name"),
|
||||
"video_type": result.get("video_type"),
|
||||
"video_url": result.get("file_url"),
|
||||
"video_filename": result.get("filename"),
|
||||
"cost": result.get("cost", 0.0),
|
||||
"resolution": request.resolution,
|
||||
"duration": request.duration,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error creating product demo video: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Product demo video generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/products/video/storytelling", summary="Create Product Storytelling Video")
|
||||
async def create_product_storytelling(
|
||||
request: ProductVideoRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
video_service: ProductVideoService = Depends(get_product_video_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Create product storytelling video (narrative-driven product showcase)."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product storytelling video")
|
||||
|
||||
# Get brand DNA
|
||||
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:
|
||||
pass
|
||||
|
||||
result = await video_service.create_product_storytelling(
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
user_id=user_id,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration,
|
||||
audio_base64=request.audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"video_type": "storytelling",
|
||||
"video_url": result.get("file_url"),
|
||||
"cost": result.get("cost", 0.0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error creating storytelling video: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/products/video/feature-highlight", summary="Create Product Feature Highlight Video")
|
||||
async def create_product_feature_highlight(
|
||||
request: ProductVideoRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
video_service: ProductVideoService = Depends(get_product_video_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Create product feature highlight video (close-up shots of key features)."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product feature highlight video")
|
||||
|
||||
# Get brand DNA
|
||||
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:
|
||||
pass
|
||||
|
||||
result = await video_service.create_product_feature_highlight(
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
user_id=user_id,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration,
|
||||
audio_base64=request.audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"video_type": "feature_highlight",
|
||||
"video_url": result.get("file_url"),
|
||||
"cost": result.get("cost", 0.0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error creating feature highlight video: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/products/video/launch", summary="Create Product Launch Video")
|
||||
async def create_product_launch(
|
||||
request: ProductVideoRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
video_service: ProductVideoService = Depends(get_product_video_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Create product launch video (exciting unveiling, launch event aesthetic)."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product launch video")
|
||||
|
||||
# Get brand DNA
|
||||
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:
|
||||
pass
|
||||
|
||||
result = await video_service.create_product_launch(
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
user_id=user_id,
|
||||
resolution=request.resolution or "1080p", # Higher quality for launch
|
||||
duration=request.duration,
|
||||
audio_base64=request.audio_base64,
|
||||
brand_context=brand_context
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"video_type": "launch",
|
||||
"video_url": result.get("file_url"),
|
||||
"cost": result.get("cost", 0.0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error creating launch video: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/products/videos/{user_id}/{filename}", summary="Serve Product Video")
|
||||
async def serve_product_video(
|
||||
user_id: str,
|
||||
filename: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Serve generated product videos."""
|
||||
try:
|
||||
from fastapi.responses import FileResponse
|
||||
from pathlib import Path
|
||||
|
||||
# Verify user owns the video
|
||||
current_user_id = _require_user_id(current_user, "serving product video")
|
||||
if current_user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Locate video file
|
||||
base_dir = Path(__file__).parent.parent.parent
|
||||
video_path = base_dir / "product_videos" / user_id / filename
|
||||
|
||||
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=filename
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error serving product video: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# HEALTH CHECK
|
||||
# ====================
|
||||
@@ -635,6 +1155,8 @@ async def health_check():
|
||||
"asset_audit": "available",
|
||||
"channel_pack": "available",
|
||||
"product_image_service": "available",
|
||||
"product_animation_service": "available",
|
||||
"product_video_service": "available",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Uses WaveSpeed AI models for high-quality video generation.
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .endpoints import create, avatar, enhance, extend, transform, models, serve, tasks, prompt, social, face_swap, video_translate, video_background_remover, add_audio_to_video
|
||||
from .endpoints import create, avatar, enhance, extend, transform, models, serve, tasks, prompt, social, face_swap, video_translate, video_background_remover, add_audio_to_video, edit
|
||||
|
||||
# Create main router
|
||||
router = APIRouter(
|
||||
@@ -32,6 +32,7 @@ router.include_router(face_swap.router)
|
||||
router.include_router(video_translate.router)
|
||||
router.include_router(video_background_remover.router)
|
||||
router.include_router(add_audio_to_video.router)
|
||||
router.include_router(edit.router)
|
||||
router.include_router(models.router)
|
||||
router.include_router(serve.router)
|
||||
router.include_router(tasks.router)
|
||||
|
||||
418
backend/routers/video_studio/endpoints/edit.py
Normal file
418
backend/routers/video_studio/endpoints/edit.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Edit Studio API endpoints.
|
||||
|
||||
Phase 1: Basic FFmpeg operations (Trim/Cut, Speed Control, Stabilization)
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import APIRouter, File, UploadFile, Form, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.middleware.auth import get_current_user, require_authenticated_user
|
||||
from backend.database.database import get_db
|
||||
from backend.services.video_studio.edit_service import EditService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/edit/trim")
|
||||
async def trim_video(
|
||||
file: UploadFile = File(..., description="Video file to trim"),
|
||||
start_time: float = Form(0.0, description="Start time in seconds"),
|
||||
end_time: Optional[float] = Form(None, description="End time in seconds (optional)"),
|
||||
max_duration: Optional[float] = Form(None, description="Maximum duration in seconds (trims if video is longer)"),
|
||||
trim_mode: str = Form("beginning", description="How to trim if max_duration is set: beginning, middle, end"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Trim video to specified duration or time range.
|
||||
|
||||
Supports:
|
||||
- Trim by start/end time
|
||||
- Trim to maximum duration
|
||||
- Trim modes: beginning, middle, end
|
||||
"""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not file.content_type.startswith('video/'):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
# Validate trim_mode
|
||||
valid_modes = ["beginning", "middle", "end"]
|
||||
if trim_mode not in valid_modes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid trim_mode. Must be one of: {', '.join(valid_modes)}"
|
||||
)
|
||||
|
||||
# Initialize service
|
||||
edit_service = EditService()
|
||||
|
||||
# Read video file
|
||||
video_data = await file.read()
|
||||
|
||||
# Trim video
|
||||
result = await edit_service.trim_video(
|
||||
video_data=video_data,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
max_duration=max_duration,
|
||||
trim_mode=trim_mode,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Video trimming failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit/speed")
|
||||
async def adjust_video_speed(
|
||||
file: UploadFile = File(..., description="Video file to adjust speed"),
|
||||
speed_factor: float = Form(..., description="Speed multiplier (0.25, 0.5, 1.0, 1.5, 2.0, 4.0)"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Adjust video playback speed.
|
||||
|
||||
Supports:
|
||||
- Slow motion: 0.25x, 0.5x
|
||||
- Normal: 1.0x
|
||||
- Fast forward: 1.5x, 2.0x, 4.0x
|
||||
"""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not file.content_type.startswith('video/'):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
# Validate speed factor
|
||||
if speed_factor <= 0 or speed_factor > 4.0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Speed factor must be between 0.25 and 4.0"
|
||||
)
|
||||
|
||||
# Initialize service
|
||||
edit_service = EditService()
|
||||
|
||||
# Read video file
|
||||
video_data = await file.read()
|
||||
|
||||
# Adjust speed
|
||||
result = await edit_service.adjust_speed(
|
||||
video_data=video_data,
|
||||
speed_factor=speed_factor,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Video speed adjustment failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit/stabilize")
|
||||
async def stabilize_video(
|
||||
file: UploadFile = File(..., description="Video file to stabilize"),
|
||||
smoothing: int = Form(10, description="Smoothing window size (1-100, default: 10)"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Stabilize shaky video using FFmpeg's vidstab filters.
|
||||
|
||||
Uses two-pass stabilization:
|
||||
1. Detect camera shake (vidstabdetect)
|
||||
2. Apply stabilization (vidstabtransform)
|
||||
|
||||
Note: Requires FFmpeg with vidstab filters enabled.
|
||||
"""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not file.content_type.startswith('video/'):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
# Validate smoothing
|
||||
if smoothing < 1 or smoothing > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Smoothing must be between 1 and 100"
|
||||
)
|
||||
|
||||
# Initialize service
|
||||
edit_service = EditService()
|
||||
|
||||
# Read video file
|
||||
video_data = await file.read()
|
||||
|
||||
# Stabilize video
|
||||
result = await edit_service.stabilize_video(
|
||||
video_data=video_data,
|
||||
smoothing=smoothing,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Video stabilization failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit/estimate-cost")
|
||||
async def estimate_edit_cost(
|
||||
edit_type: str = Form(..., description="Type of edit: trim, speed, stabilize, text, volume, normalize, denoise"),
|
||||
duration: float = Form(10.0, description="Estimated video duration in seconds"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Estimate cost for video editing operation.
|
||||
|
||||
Note: FFmpeg-based operations are free.
|
||||
AI-based operations will have costs (Phase 3).
|
||||
"""
|
||||
try:
|
||||
require_authenticated_user(current_user)
|
||||
|
||||
edit_service = EditService()
|
||||
estimated_cost = edit_service.calculate_cost(edit_type, duration)
|
||||
|
||||
return {
|
||||
"estimated_cost": estimated_cost,
|
||||
"edit_type": edit_type,
|
||||
"estimated_duration": duration,
|
||||
"pricing_model": "free", # FFmpeg operations are free
|
||||
"note": "FFmpeg-based editing operations are free. AI-based operations may have costs.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Cost estimation failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== Phase 2: Text & Audio Endpoints ====================
|
||||
|
||||
@router.post("/edit/text")
|
||||
async def add_text_overlay(
|
||||
file: UploadFile = File(..., description="Video file to add text overlay"),
|
||||
text: str = Form(..., description="Text to overlay on video"),
|
||||
position: str = Form("center", description="Text position: top, center, bottom, top-left, top-right, bottom-left, bottom-right"),
|
||||
font_size: int = Form(48, description="Font size in pixels"),
|
||||
font_color: str = Form("white", description="Font color (e.g., white, #FFFFFF)"),
|
||||
background_color: str = Form("black@0.5", description="Background color with opacity (e.g., black@0.5)"),
|
||||
start_time: float = Form(0.0, description="When to start showing text (seconds)"),
|
||||
end_time: Optional[float] = Form(None, description="When to stop showing text (None = end of video)"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add text overlay to video using FFmpeg drawtext filter.
|
||||
|
||||
Supports:
|
||||
- Multiple positions (center, top, bottom, corners)
|
||||
- Custom font size and colors
|
||||
- Background box with opacity
|
||||
- Time-limited display (show text only during specific time range)
|
||||
"""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not file.content_type.startswith('video/'):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
valid_positions = ["top", "center", "bottom", "top-left", "top-right", "bottom-left", "bottom-right"]
|
||||
if position not in valid_positions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid position. Must be one of: {', '.join(valid_positions)}"
|
||||
)
|
||||
|
||||
edit_service = EditService()
|
||||
video_data = await file.read()
|
||||
|
||||
result = await edit_service.add_text_overlay(
|
||||
video_data=video_data,
|
||||
text=text,
|
||||
position=position,
|
||||
font_size=font_size,
|
||||
font_color=font_color,
|
||||
background_color=background_color,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Text overlay failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit/volume")
|
||||
async def adjust_volume(
|
||||
file: UploadFile = File(..., description="Video file to adjust volume"),
|
||||
volume_factor: float = Form(..., description="Volume multiplier (0.0 = mute, 1.0 = original, 2.0 = double)"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Adjust video audio volume.
|
||||
|
||||
Supports:
|
||||
- Mute (0.0)
|
||||
- Reduce volume (0.0 - 1.0)
|
||||
- Original (1.0)
|
||||
- Increase volume (1.0 - 3.0+)
|
||||
"""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not file.content_type.startswith('video/'):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
if volume_factor < 0:
|
||||
raise HTTPException(status_code=400, detail="Volume factor must be non-negative")
|
||||
|
||||
if volume_factor > 5.0:
|
||||
raise HTTPException(status_code=400, detail="Volume factor cannot exceed 5.0 to prevent distortion")
|
||||
|
||||
edit_service = EditService()
|
||||
video_data = await file.read()
|
||||
|
||||
result = await edit_service.adjust_volume(
|
||||
video_data=video_data,
|
||||
volume_factor=volume_factor,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Volume adjustment failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit/normalize")
|
||||
async def normalize_audio(
|
||||
file: UploadFile = File(..., description="Video file to normalize audio"),
|
||||
target_level: float = Form(-14.0, description="Target integrated loudness in LUFS (default: -14 for streaming)"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Normalize audio levels using EBU R128 standard (loudnorm filter).
|
||||
|
||||
Common target levels:
|
||||
- -14 LUFS: YouTube, Spotify, general streaming
|
||||
- -16 LUFS: Podcast standard
|
||||
- -23 LUFS: Broadcast TV (EBU R128)
|
||||
- -24 LUFS: US Broadcast (ATSC A/85)
|
||||
"""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not file.content_type.startswith('video/'):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
if target_level > 0 or target_level < -50:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Target level must be between -50 and 0 LUFS"
|
||||
)
|
||||
|
||||
edit_service = EditService()
|
||||
video_data = await file.read()
|
||||
|
||||
result = await edit_service.normalize_audio(
|
||||
video_data=video_data,
|
||||
target_level=target_level,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Audio normalization failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit/denoise")
|
||||
async def reduce_noise(
|
||||
file: UploadFile = File(..., description="Video file to reduce audio noise"),
|
||||
strength: float = Form(0.5, description="Noise reduction strength (0.0 - 1.0)"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Reduce audio noise using FFmpeg's noise reduction filters.
|
||||
|
||||
Supports:
|
||||
- Light noise reduction (0.0 - 0.3): Subtle cleanup
|
||||
- Moderate reduction (0.3 - 0.6): Good for background noise
|
||||
- Strong reduction (0.6 - 1.0): Heavy noise, may affect audio quality
|
||||
"""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not file.content_type.startswith('video/'):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
if strength < 0 or strength > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Strength must be between 0.0 and 1.0"
|
||||
)
|
||||
|
||||
edit_service = EditService()
|
||||
video_data = await file.read()
|
||||
|
||||
result = await edit_service.reduce_noise(
|
||||
video_data=video_data,
|
||||
noise_reduction_strength=strength,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Noise reduction failed: {str(e)}"
|
||||
)
|
||||
Reference in New Issue
Block a user