207 lines
8.0 KiB
Python
207 lines
8.0 KiB
Python
"""
|
|
Face Swap service for Video Studio.
|
|
|
|
Supports two models:
|
|
1. MoCha (wavespeed-ai/wan-2.1/mocha) - Character replacement with motion preservation
|
|
2. Video Face Swap (wavespeed-ai/video-face-swap) - Simple face swap with multi-face support
|
|
"""
|
|
|
|
import base64
|
|
from typing import Dict, Any, Optional, Callable
|
|
from fastapi import HTTPException
|
|
|
|
from utils.logger_utils import get_service_logger
|
|
from ..wavespeed.client import WaveSpeedClient
|
|
|
|
logger = get_service_logger("video_studio.face_swap")
|
|
|
|
|
|
class FaceSwapService:
|
|
"""Service for face/character swap operations."""
|
|
|
|
def __init__(self):
|
|
"""Initialize Face Swap service."""
|
|
self.wavespeed_client = WaveSpeedClient()
|
|
logger.info("[FaceSwap] Service initialized")
|
|
|
|
def calculate_cost(self, model: str, resolution: Optional[str] = None, duration: float = 10.0) -> float:
|
|
"""
|
|
Calculate cost for face swap operation.
|
|
|
|
Args:
|
|
model: Model to use ("mocha" or "video-face-swap")
|
|
resolution: Output resolution for MoCha ("480p" or "720p"), ignored for video-face-swap
|
|
duration: Video duration in seconds
|
|
|
|
Returns:
|
|
Cost in USD
|
|
"""
|
|
if model == "video-face-swap":
|
|
# Video Face Swap pricing: $0.01/s
|
|
# Minimum charge: 5 seconds
|
|
# Maximum: 600 seconds (10 minutes)
|
|
cost_per_second = 0.01
|
|
billed_duration = max(5.0, min(duration, 600.0))
|
|
return cost_per_second * billed_duration
|
|
else:
|
|
# MoCha pricing: $0.04/s (480p), $0.08/s (720p)
|
|
# Minimum charge: 5 seconds
|
|
# Maximum billed: 120 seconds
|
|
pricing = {
|
|
"480p": 0.04,
|
|
"720p": 0.08,
|
|
}
|
|
cost_per_second = pricing.get(resolution or "480p", pricing["480p"])
|
|
billed_duration = max(5.0, min(duration, 120.0))
|
|
return cost_per_second * billed_duration
|
|
|
|
async def swap_face(
|
|
self,
|
|
image_data: bytes,
|
|
video_data: bytes,
|
|
model: str = "mocha",
|
|
prompt: Optional[str] = None,
|
|
resolution: str = "480p",
|
|
seed: Optional[int] = None,
|
|
target_gender: str = "all",
|
|
target_index: int = 0,
|
|
user_id: str = None,
|
|
progress_callback: Optional[Callable[[float, str], None]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Perform face/character swap using MoCha or Video Face Swap.
|
|
|
|
Args:
|
|
image_data: Reference image as bytes
|
|
video_data: Source video as bytes
|
|
model: Model to use ("mocha" or "video-face-swap")
|
|
prompt: Optional prompt to guide the swap (MoCha only)
|
|
resolution: Output resolution for MoCha ("480p" or "720p")
|
|
seed: Random seed for reproducibility (MoCha only)
|
|
target_gender: Filter which faces to swap (video-face-swap only: "all", "female", "male")
|
|
target_index: Select which face to swap (video-face-swap only: 0 = largest)
|
|
user_id: User ID for tracking
|
|
progress_callback: Optional callback for progress updates
|
|
|
|
Returns:
|
|
Dict with swapped video_url, cost, and metadata
|
|
"""
|
|
try:
|
|
logger.info(
|
|
f"[FaceSwap] Face swap request: user={user_id}, "
|
|
f"model={model}, resolution={resolution if model == 'mocha' else 'N/A'}"
|
|
)
|
|
|
|
if not user_id:
|
|
raise ValueError("user_id is required for face swap")
|
|
|
|
# Validate model
|
|
if model not in ("mocha", "video-face-swap"):
|
|
raise ValueError("Model must be 'mocha' or 'video-face-swap'")
|
|
|
|
# Convert image to base64 data URI
|
|
image_b64 = base64.b64encode(image_data).decode('utf-8')
|
|
image_uri = f"data:image/png;base64,{image_b64}"
|
|
|
|
# Convert video to base64 data URI
|
|
video_b64 = base64.b64encode(video_data).decode('utf-8')
|
|
video_uri = f"data:video/mp4;base64,{video_b64}"
|
|
|
|
# Estimate duration (we'll use a default, actual duration would come from video metadata)
|
|
estimated_duration = 10.0 # Default estimate, should be improved with actual video duration
|
|
|
|
# Calculate cost estimate
|
|
cost = self.calculate_cost(model, resolution if model == "mocha" else None, estimated_duration)
|
|
|
|
if progress_callback:
|
|
model_name = "MoCha" if model == "mocha" else "Video Face Swap"
|
|
progress_callback(10.0, f"Submitting face swap request to {model_name}...")
|
|
|
|
# Perform face swap based on model
|
|
if model == "mocha":
|
|
# Validate resolution for MoCha
|
|
if resolution not in ("480p", "720p"):
|
|
raise ValueError("Resolution must be '480p' or '720p' for MoCha")
|
|
|
|
# face_swap is synchronous (uses sync_mode internally)
|
|
swapped_video_bytes = self.wavespeed_client.face_swap(
|
|
image=image_uri,
|
|
video=video_uri,
|
|
prompt=prompt,
|
|
resolution=resolution,
|
|
seed=seed,
|
|
enable_sync_mode=True,
|
|
timeout=600, # 10 minutes timeout
|
|
progress_callback=progress_callback,
|
|
)
|
|
else: # video-face-swap
|
|
# video_face_swap is synchronous (uses sync_mode internally)
|
|
swapped_video_bytes = self.wavespeed_client.video_face_swap(
|
|
video=video_uri,
|
|
face_image=image_uri,
|
|
target_gender=target_gender,
|
|
target_index=target_index,
|
|
enable_sync_mode=True,
|
|
timeout=600, # 10 minutes timeout
|
|
progress_callback=progress_callback,
|
|
)
|
|
|
|
if progress_callback:
|
|
progress_callback(90.0, "Face swap complete, saving video...")
|
|
|
|
# Save swapped video
|
|
from . import VideoStudioService
|
|
video_service = VideoStudioService()
|
|
save_result = video_service._save_video_file(
|
|
video_bytes=swapped_video_bytes,
|
|
operation_type="face_swap",
|
|
user_id=user_id,
|
|
)
|
|
|
|
# Recalculate cost with actual duration if available
|
|
# For now, use estimated cost
|
|
actual_cost = cost
|
|
|
|
logger.info(
|
|
f"[FaceSwap] Face swap successful: user={user_id}, "
|
|
f"resolution={resolution}, cost=${actual_cost:.4f}"
|
|
)
|
|
|
|
metadata = {
|
|
"original_image_size": len(image_data),
|
|
"original_video_size": len(video_data),
|
|
"swapped_video_size": len(swapped_video_bytes),
|
|
"model": model,
|
|
}
|
|
|
|
if model == "mocha":
|
|
metadata.update({
|
|
"resolution": resolution,
|
|
"seed": seed,
|
|
"prompt": prompt,
|
|
})
|
|
else: # video-face-swap
|
|
metadata.update({
|
|
"target_gender": target_gender,
|
|
"target_index": target_index,
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"video_url": save_result["file_url"],
|
|
"video_bytes": swapped_video_bytes,
|
|
"cost": actual_cost,
|
|
"model": model,
|
|
"resolution": resolution if model == "mocha" else None,
|
|
"metadata": metadata,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[FaceSwap] Face swap error: {e}", exc_info=True)
|
|
return {
|
|
"success": False,
|
|
"error": str(e)
|
|
}
|