AI Researcher and Video Studio implementation complete
This commit is contained in:
@@ -6,6 +6,8 @@ from .edit_service import EditStudioService, EditStudioRequest
|
||||
from .upscale_service import UpscaleStudioService, UpscaleStudioRequest
|
||||
from .control_service import ControlStudioService, ControlStudioRequest
|
||||
from .social_optimizer_service import SocialOptimizerService, SocialOptimizerRequest
|
||||
from .compression_service import ImageCompressionService, CompressionRequest, CompressionResult
|
||||
from .format_converter_service import ImageFormatConverterService, FormatConversionRequest, FormatConversionResult
|
||||
from .transform_service import (
|
||||
TransformStudioService,
|
||||
TransformImageToVideoRequest,
|
||||
@@ -25,6 +27,12 @@ __all__ = [
|
||||
"ControlStudioRequest",
|
||||
"SocialOptimizerService",
|
||||
"SocialOptimizerRequest",
|
||||
"ImageCompressionService",
|
||||
"CompressionRequest",
|
||||
"CompressionResult",
|
||||
"ImageFormatConverterService",
|
||||
"FormatConversionRequest",
|
||||
"FormatConversionResult",
|
||||
"TransformStudioService",
|
||||
"TransformImageToVideoRequest",
|
||||
"TalkingAvatarRequest",
|
||||
|
||||
367
backend/services/image_studio/compression_service.py
Normal file
367
backend/services/image_studio/compression_service.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""Image Compression Service for optimizing image file sizes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
|
||||
from PIL import Image, ExifTags
|
||||
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
|
||||
logger = get_service_logger("image_studio.compression")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompressionRequest:
|
||||
"""Request model for image compression."""
|
||||
image_base64: str
|
||||
quality: int = 85 # 1-100, where 100 is best quality
|
||||
format: str = "jpeg" # jpeg, png, webp, avif
|
||||
target_size_kb: Optional[int] = None # Target file size in KB
|
||||
strip_metadata: bool = True
|
||||
progressive: bool = True # Progressive JPEG
|
||||
optimize: bool = True # Optimize encoding
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompressionResult:
|
||||
"""Result of compression operation."""
|
||||
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 ImageCompressionService:
|
||||
"""Service for image compression and optimization."""
|
||||
|
||||
SUPPORTED_FORMATS = ["jpeg", "jpg", "png", "webp"]
|
||||
|
||||
# Format-specific options
|
||||
FORMAT_OPTIONS = {
|
||||
"jpeg": {"quality": (1, 100), "progressive": True, "optimize": True},
|
||||
"jpg": {"quality": (1, 100), "progressive": True, "optimize": True},
|
||||
"png": {"compress_level": (0, 9), "optimize": True},
|
||||
"webp": {"quality": (1, 100), "lossless": False},
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
logger.info("[Compression] ImageCompressionService initialized")
|
||||
|
||||
def _decode_image(self, image_base64: str) -> tuple[Image.Image, int]:
|
||||
"""Decode base64 image and return PIL Image and original size."""
|
||||
# Handle data URL format
|
||||
if "," in image_base64:
|
||||
image_base64 = image_base64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(image_base64)
|
||||
original_size = len(image_bytes)
|
||||
|
||||
image = Image.open(io.BytesIO(image_bytes))
|
||||
return image, original_size
|
||||
|
||||
def _strip_exif(self, image: Image.Image) -> Image.Image:
|
||||
"""Remove EXIF metadata from image."""
|
||||
# Create a new image without EXIF data
|
||||
data = list(image.getdata())
|
||||
image_without_exif = Image.new(image.mode, image.size)
|
||||
image_without_exif.putdata(data)
|
||||
return image_without_exif
|
||||
|
||||
def _compress_to_target_size(
|
||||
self,
|
||||
image: Image.Image,
|
||||
target_size_kb: int,
|
||||
format: str,
|
||||
min_quality: int = 10,
|
||||
max_quality: int = 95,
|
||||
) -> tuple[bytes, int]:
|
||||
"""Compress image to target file size using binary search."""
|
||||
target_bytes = target_size_kb * 1024
|
||||
|
||||
low, high = min_quality, max_quality
|
||||
best_result = None
|
||||
best_quality = max_quality
|
||||
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
compressed = self._compress_image(image, format, mid, True, True)
|
||||
|
||||
if len(compressed) <= target_bytes:
|
||||
best_result = compressed
|
||||
best_quality = mid
|
||||
low = mid + 1 # Try higher quality
|
||||
else:
|
||||
high = mid - 1 # Try lower quality
|
||||
|
||||
if best_result is None:
|
||||
# Even minimum quality exceeds target, return min quality result
|
||||
best_result = self._compress_image(image, format, min_quality, True, True)
|
||||
best_quality = min_quality
|
||||
|
||||
return best_result, best_quality
|
||||
|
||||
def _compress_image(
|
||||
self,
|
||||
image: Image.Image,
|
||||
format: str,
|
||||
quality: int,
|
||||
progressive: bool,
|
||||
optimize: bool,
|
||||
) -> bytes:
|
||||
"""Compress image with given settings."""
|
||||
buffer = io.BytesIO()
|
||||
|
||||
# Handle format-specific options
|
||||
save_kwargs: Dict[str, Any] = {}
|
||||
|
||||
format_lower = format.lower()
|
||||
if format_lower in ["jpeg", "jpg"]:
|
||||
# Convert to RGB if necessary (JPEG doesn't support alpha)
|
||||
if image.mode in ("RGBA", "P"):
|
||||
image = image.convert("RGB")
|
||||
save_kwargs["format"] = "JPEG"
|
||||
save_kwargs["quality"] = quality
|
||||
save_kwargs["optimize"] = optimize
|
||||
if progressive:
|
||||
save_kwargs["progressive"] = True
|
||||
elif format_lower == "png":
|
||||
save_kwargs["format"] = "PNG"
|
||||
save_kwargs["optimize"] = optimize
|
||||
# PNG uses compress_level (0-9) instead of quality
|
||||
compress_level = max(0, min(9, (100 - quality) // 11))
|
||||
save_kwargs["compress_level"] = compress_level
|
||||
elif format_lower == "webp":
|
||||
save_kwargs["format"] = "WEBP"
|
||||
save_kwargs["quality"] = quality
|
||||
save_kwargs["method"] = 6 # Best compression
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format}")
|
||||
|
||||
image.save(buffer, **save_kwargs)
|
||||
return buffer.getvalue()
|
||||
|
||||
async def compress(
|
||||
self,
|
||||
request: CompressionRequest,
|
||||
user_id: Optional[str] = None,
|
||||
) -> CompressionResult:
|
||||
"""Compress an image with specified settings."""
|
||||
logger.info(f"[Compression] Processing compression request for user: {user_id}")
|
||||
|
||||
try:
|
||||
# Decode image
|
||||
image, original_size = self._decode_image(request.image_base64)
|
||||
original_size_kb = original_size / 1024
|
||||
|
||||
logger.info(f"[Compression] Original size: {original_size_kb:.2f} KB, dimensions: {image.size}")
|
||||
|
||||
# Strip metadata if requested
|
||||
if request.strip_metadata:
|
||||
image = self._strip_exif(image)
|
||||
|
||||
# Validate format
|
||||
format_lower = request.format.lower()
|
||||
if format_lower not in self.SUPPORTED_FORMATS:
|
||||
raise ValueError(f"Unsupported format: {request.format}. Supported: {self.SUPPORTED_FORMATS}")
|
||||
|
||||
# Compress to target size or with quality setting
|
||||
if request.target_size_kb:
|
||||
compressed_bytes, quality_used = self._compress_to_target_size(
|
||||
image,
|
||||
request.target_size_kb,
|
||||
format_lower,
|
||||
)
|
||||
else:
|
||||
compressed_bytes = self._compress_image(
|
||||
image,
|
||||
format_lower,
|
||||
request.quality,
|
||||
request.progressive,
|
||||
request.optimize,
|
||||
)
|
||||
quality_used = request.quality
|
||||
|
||||
compressed_size_kb = len(compressed_bytes) / 1024
|
||||
compression_ratio = (1 - compressed_size_kb / original_size_kb) * 100 if original_size_kb > 0 else 0
|
||||
|
||||
# Encode result
|
||||
mime_type = "image/jpeg" if format_lower in ["jpeg", "jpg"] else f"image/{format_lower}"
|
||||
result_base64 = f"data:{mime_type};base64,{base64.b64encode(compressed_bytes).decode()}"
|
||||
|
||||
logger.info(f"[Compression] Compressed: {original_size_kb:.2f}KB → {compressed_size_kb:.2f}KB ({compression_ratio:.1f}% reduction)")
|
||||
|
||||
return CompressionResult(
|
||||
success=True,
|
||||
image_base64=result_base64,
|
||||
original_size_kb=round(original_size_kb, 2),
|
||||
compressed_size_kb=round(compressed_size_kb, 2),
|
||||
compression_ratio=round(compression_ratio, 2),
|
||||
format=format_lower,
|
||||
width=image.width,
|
||||
height=image.height,
|
||||
quality_used=quality_used,
|
||||
metadata_stripped=request.strip_metadata,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Compression] Failed to compress image: {e}")
|
||||
raise
|
||||
|
||||
async def compress_batch(
|
||||
self,
|
||||
requests: List[CompressionRequest],
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[CompressionResult]:
|
||||
"""Compress multiple images with same or individual settings."""
|
||||
logger.info(f"[Compression] Processing batch of {len(requests)} images for user: {user_id}")
|
||||
|
||||
results = []
|
||||
for i, request in enumerate(requests):
|
||||
try:
|
||||
result = await self.compress(request, user_id)
|
||||
results.append(result)
|
||||
logger.info(f"[Compression] Batch item {i+1}/{len(requests)} complete")
|
||||
except Exception as e:
|
||||
logger.error(f"[Compression] Batch item {i+1} failed: {e}")
|
||||
# Return partial success
|
||||
results.append(CompressionResult(
|
||||
success=False,
|
||||
image_base64="",
|
||||
original_size_kb=0,
|
||||
compressed_size_kb=0,
|
||||
compression_ratio=0,
|
||||
format="",
|
||||
width=0,
|
||||
height=0,
|
||||
quality_used=0,
|
||||
metadata_stripped=False,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
async def estimate_compression(
|
||||
self,
|
||||
image_base64: str,
|
||||
format: str = "jpeg",
|
||||
quality: int = 85,
|
||||
) -> Dict[str, Any]:
|
||||
"""Estimate compression results without actually compressing."""
|
||||
try:
|
||||
image, original_size = self._decode_image(image_base64)
|
||||
original_size_kb = original_size / 1024
|
||||
|
||||
# Quick estimation based on format and quality
|
||||
if format.lower() in ["jpeg", "jpg"]:
|
||||
# JPEG compression ratio estimate
|
||||
estimated_ratio = 0.1 + (quality / 100) * 0.4 # 10-50% of original
|
||||
elif format.lower() == "webp":
|
||||
# WebP is typically 25-34% smaller than JPEG
|
||||
estimated_ratio = 0.08 + (quality / 100) * 0.35
|
||||
else: # PNG
|
||||
estimated_ratio = 0.7 + (quality / 100) * 0.2 # PNG is less compressible
|
||||
|
||||
estimated_size_kb = original_size_kb * estimated_ratio
|
||||
|
||||
return {
|
||||
"original_size_kb": round(original_size_kb, 2),
|
||||
"estimated_size_kb": round(estimated_size_kb, 2),
|
||||
"estimated_reduction_percent": round((1 - estimated_ratio) * 100, 1),
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"format": format.lower(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Compression] Estimation failed: {e}")
|
||||
raise
|
||||
|
||||
def get_supported_formats(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of supported compression formats with details."""
|
||||
return [
|
||||
{
|
||||
"id": "jpeg",
|
||||
"name": "JPEG",
|
||||
"extension": ".jpg",
|
||||
"description": "Best for photos. Lossy compression with excellent size reduction.",
|
||||
"supports_transparency": False,
|
||||
"quality_range": [1, 100],
|
||||
"recommended_quality": 85,
|
||||
"use_cases": ["Photos", "Blog images", "Email", "Social media"],
|
||||
},
|
||||
{
|
||||
"id": "png",
|
||||
"name": "PNG",
|
||||
"extension": ".png",
|
||||
"description": "Best for graphics with transparency. Lossless compression.",
|
||||
"supports_transparency": True,
|
||||
"quality_range": [1, 100],
|
||||
"recommended_quality": 90,
|
||||
"use_cases": ["Logos", "Icons", "Graphics", "Screenshots"],
|
||||
},
|
||||
{
|
||||
"id": "webp",
|
||||
"name": "WebP",
|
||||
"extension": ".webp",
|
||||
"description": "Modern format with excellent compression. 25-34% smaller than JPEG.",
|
||||
"supports_transparency": True,
|
||||
"quality_range": [1, 100],
|
||||
"recommended_quality": 80,
|
||||
"use_cases": ["Web images", "Fast loading", "Modern browsers"],
|
||||
},
|
||||
]
|
||||
|
||||
def get_presets(self) -> List[Dict[str, Any]]:
|
||||
"""Get compression presets for common use cases."""
|
||||
return [
|
||||
{
|
||||
"id": "web",
|
||||
"name": "Web Optimized",
|
||||
"description": "Balanced quality and size for web pages",
|
||||
"format": "webp",
|
||||
"quality": 80,
|
||||
"strip_metadata": True,
|
||||
},
|
||||
{
|
||||
"id": "email",
|
||||
"name": "Email Friendly",
|
||||
"description": "Small file size for email attachments (<200KB target)",
|
||||
"format": "jpeg",
|
||||
"quality": 70,
|
||||
"target_size_kb": 200,
|
||||
"strip_metadata": True,
|
||||
},
|
||||
{
|
||||
"id": "social",
|
||||
"name": "Social Media",
|
||||
"description": "Optimized for social platforms",
|
||||
"format": "jpeg",
|
||||
"quality": 85,
|
||||
"strip_metadata": True,
|
||||
},
|
||||
{
|
||||
"id": "high_quality",
|
||||
"name": "High Quality",
|
||||
"description": "Minimal compression for quality-critical images",
|
||||
"format": "png",
|
||||
"quality": 95,
|
||||
"strip_metadata": False,
|
||||
},
|
||||
{
|
||||
"id": "maximum",
|
||||
"name": "Maximum Compression",
|
||||
"description": "Smallest possible file size",
|
||||
"format": "webp",
|
||||
"quality": 60,
|
||||
"strip_metadata": True,
|
||||
},
|
||||
]
|
||||
@@ -1,17 +1,10 @@
|
||||
"""Create Studio service for AI-powered image generation."""
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, Any, List, Literal
|
||||
from dataclasses import dataclass
|
||||
|
||||
from services.llm_providers.image_generation import (
|
||||
ImageGenerationOptions,
|
||||
ImageGenerationResult,
|
||||
HuggingFaceImageProvider,
|
||||
GeminiImageProvider,
|
||||
StabilityImageProvider,
|
||||
WaveSpeedImageProvider,
|
||||
)
|
||||
from services.llm_providers.main_image_generation import generate_image
|
||||
from services.llm_providers.image_generation import ImageGenerationResult
|
||||
from .templates import TemplateManager, ImageTemplate, Platform, TemplateCategory
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
@@ -75,29 +68,8 @@ class CreateStudioService:
|
||||
self.template_manager = TemplateManager()
|
||||
logger.info("[Create Studio] Initialized with template manager")
|
||||
|
||||
def _get_provider_instance(self, provider_name: str, api_key: Optional[str] = None):
|
||||
"""Get provider instance by name.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
api_key: Optional API key (uses env vars if not provided)
|
||||
|
||||
Returns:
|
||||
Provider instance
|
||||
|
||||
Raises:
|
||||
ValueError: If provider is not supported
|
||||
"""
|
||||
if provider_name == "stability":
|
||||
return StabilityImageProvider(api_key=api_key or os.getenv("STABILITY_API_KEY"))
|
||||
elif provider_name == "wavespeed":
|
||||
return WaveSpeedImageProvider(api_key=api_key or os.getenv("WAVESPEED_API_KEY"))
|
||||
elif provider_name == "huggingface":
|
||||
return HuggingFaceImageProvider(api_token=api_key or os.getenv("HF_API_KEY"))
|
||||
elif provider_name == "gemini":
|
||||
return GeminiImageProvider(api_key=api_key or os.getenv("GEMINI_API_KEY"))
|
||||
else:
|
||||
raise ValueError(f"Unsupported provider: {provider_name}")
|
||||
# Removed _get_provider_instance() - now using unified entry point
|
||||
# Provider selection is handled by main_image_generation.generate_image()
|
||||
|
||||
def _select_provider_and_model(
|
||||
self,
|
||||
@@ -289,30 +261,17 @@ class CreateStudioService:
|
||||
logger.info("[Create Studio] Starting generation: prompt=%s, template=%s",
|
||||
request.prompt[:100], request.template_id)
|
||||
|
||||
# Pre-flight validation: Check subscription and usage limits
|
||||
if user_id:
|
||||
from services.database import get_db
|
||||
from services.subscription import PricingService
|
||||
from services.subscription.preflight_validator import validate_image_generation_operations
|
||||
from fastapi import HTTPException
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
pricing_service = PricingService(db)
|
||||
logger.info(f"[Create Studio] 🛂 Running pre-flight validation for user {user_id}")
|
||||
validate_image_generation_operations(
|
||||
pricing_service=pricing_service,
|
||||
user_id=user_id,
|
||||
num_images=request.num_variations
|
||||
)
|
||||
logger.info(f"[Create Studio] ✅ Pre-flight validation passed - proceeding with generation")
|
||||
except HTTPException as http_ex:
|
||||
logger.error(f"[Create Studio] ❌ Pre-flight validation failed - blocking generation")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
logger.warning("[Create Studio] ⚠️ No user_id provided - skipping pre-flight validation")
|
||||
# Pre-flight validation: Reuse unified helper
|
||||
# Note: Validation for num_variations will be done per-image in generate_image()
|
||||
# We validate once upfront to fail fast if user has no credits
|
||||
if user_id and request.num_variations > 0:
|
||||
from services.llm_providers.main_image_generation import _validate_image_operation
|
||||
_validate_image_operation(
|
||||
user_id=user_id,
|
||||
operation_type="create-studio-generation",
|
||||
num_operations=request.num_variations,
|
||||
log_prefix="[Create Studio]"
|
||||
)
|
||||
|
||||
# Load template if specified
|
||||
template = None
|
||||
@@ -337,36 +296,37 @@ class CreateStudioService:
|
||||
# Select provider and model
|
||||
provider_name, model = self._select_provider_and_model(request, template)
|
||||
|
||||
# Get provider instance
|
||||
try:
|
||||
provider = self._get_provider_instance(provider_name)
|
||||
except Exception as e:
|
||||
logger.error("[Create Studio] ❌ Failed to initialize provider %s: %s",
|
||||
provider_name, str(e))
|
||||
raise RuntimeError(f"Provider initialization failed: {str(e)}")
|
||||
|
||||
# Generate images
|
||||
# Generate images using unified entry point
|
||||
# This ensures consistent validation, tracking, and error handling
|
||||
results = []
|
||||
for i in range(request.num_variations):
|
||||
logger.info("[Create Studio] Generating variation %d/%d",
|
||||
i + 1, request.num_variations)
|
||||
|
||||
try:
|
||||
# Prepare options
|
||||
options = ImageGenerationOptions(
|
||||
prompt=prompt,
|
||||
negative_prompt=request.negative_prompt,
|
||||
width=width,
|
||||
height=height,
|
||||
guidance_scale=request.guidance_scale,
|
||||
steps=request.steps,
|
||||
seed=request.seed + i if request.seed else None,
|
||||
model=model,
|
||||
extra={"style_preset": request.style_preset} if request.style_preset else {}
|
||||
)
|
||||
# Prepare options for unified entry point
|
||||
options = {
|
||||
"provider": provider_name,
|
||||
"model": model,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"negative_prompt": request.negative_prompt,
|
||||
"guidance_scale": request.guidance_scale,
|
||||
"steps": request.steps,
|
||||
"seed": request.seed + i if request.seed else None,
|
||||
}
|
||||
|
||||
# Generate image
|
||||
result: ImageGenerationResult = provider.generate(options)
|
||||
# Add style preset to extra if specified
|
||||
if request.style_preset:
|
||||
options["extra"] = {"style_preset": request.style_preset}
|
||||
|
||||
# Generate image using unified entry point
|
||||
# This handles validation, provider selection, generation, and tracking automatically
|
||||
result: ImageGenerationResult = generate_image(
|
||||
prompt=prompt,
|
||||
options=options,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
results.append({
|
||||
"image_bytes": result.image_bytes,
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, Dict, Literal, Optional
|
||||
from PIL import Image
|
||||
|
||||
from services.llm_providers.main_image_editing import edit_image as huggingface_edit_image
|
||||
from services.llm_providers.main_image_generation import generate_image_edit
|
||||
from services.stability_service import StabilityAIService
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
@@ -213,6 +214,249 @@ class EditStudioService:
|
||||
def list_operations(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Expose supported operations for UI rendering."""
|
||||
return self.SUPPORTED_OPERATIONS
|
||||
|
||||
def get_available_models(
|
||||
self,
|
||||
operation: Optional[str] = None,
|
||||
tier: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get available WaveSpeed editing models.
|
||||
|
||||
Args:
|
||||
operation: Filter by operation type (e.g., "general_edit")
|
||||
tier: Filter by tier ("budget", "mid", "premium")
|
||||
|
||||
Returns:
|
||||
Dictionary with models and metadata
|
||||
"""
|
||||
from services.llm_providers.image_generation.wavespeed_edit_provider import WaveSpeedEditProvider
|
||||
|
||||
provider = WaveSpeedEditProvider()
|
||||
all_models = provider.get_available_models()
|
||||
|
||||
# Filter by operation if specified
|
||||
if operation:
|
||||
filtered = provider.get_models_by_operation(operation)
|
||||
all_models = {k: v for k, v in all_models.items() if k in filtered}
|
||||
|
||||
# Filter by tier if specified
|
||||
if tier:
|
||||
filtered = provider.get_models_by_tier(tier)
|
||||
all_models = {k: v for k, v in all_models.items() if k in filtered}
|
||||
|
||||
# Format for API response
|
||||
models_list = []
|
||||
for model_id, model_info in all_models.items():
|
||||
models_list.append({
|
||||
"id": model_id,
|
||||
"name": model_info.get("name", model_id),
|
||||
"description": model_info.get("description", ""),
|
||||
"cost": model_info.get("cost", 0.02),
|
||||
"cost_8k": model_info.get("cost_8k"), # Optional
|
||||
"tier": model_info.get("tier", "mid"),
|
||||
"max_resolution": model_info.get("max_resolution", [2048, 2048]),
|
||||
"capabilities": model_info.get("capabilities", []),
|
||||
"use_cases": self._get_use_cases_for_model(model_id, model_info),
|
||||
"features": self._get_features_for_model(model_info),
|
||||
"supports_multi_image": model_info.get("supports_multi_image", False),
|
||||
"supports_controlnet": model_info.get("supports_controlnet", False),
|
||||
"languages": model_info.get("languages", ["en"]),
|
||||
})
|
||||
|
||||
return {
|
||||
"models": models_list,
|
||||
"total": len(models_list),
|
||||
}
|
||||
|
||||
def recommend_model(
|
||||
self,
|
||||
operation: str,
|
||||
image_resolution: Optional[Dict[str, int]] = None,
|
||||
user_tier: Optional[str] = None,
|
||||
preferences: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Recommend best model for given operation and context.
|
||||
|
||||
Args:
|
||||
operation: Operation type (e.g., "general_edit")
|
||||
image_resolution: Dict with "width" and "height"
|
||||
user_tier: User subscription tier ("free", "pro", "enterprise")
|
||||
preferences: Dict with "prioritize_cost" or "prioritize_quality"
|
||||
|
||||
Returns:
|
||||
Dictionary with recommended model and alternatives
|
||||
"""
|
||||
from services.llm_providers.image_generation.wavespeed_edit_provider import WaveSpeedEditProvider
|
||||
|
||||
provider = WaveSpeedEditProvider()
|
||||
available_models = provider.get_models_by_operation(operation)
|
||||
|
||||
if not available_models:
|
||||
# Fallback to all models if operation doesn't match
|
||||
available_models = provider.get_available_models()
|
||||
|
||||
# Filter by resolution if provided
|
||||
if image_resolution:
|
||||
width = image_resolution.get("width", 0)
|
||||
height = image_resolution.get("height", 0)
|
||||
max_dimension = max(width, height)
|
||||
|
||||
# Filter models that support this resolution
|
||||
filtered = {}
|
||||
for model_id, model_info in available_models.items():
|
||||
max_res = model_info.get("max_resolution", (2048, 2048))
|
||||
max_supported = max(max_res[0], max_res[1])
|
||||
if max_dimension <= max_supported:
|
||||
filtered[model_id] = model_info
|
||||
available_models = filtered
|
||||
|
||||
if not available_models:
|
||||
# No models match, return first available
|
||||
all_models = provider.get_available_models()
|
||||
if all_models:
|
||||
first_model_id = list(all_models.keys())[0]
|
||||
return {
|
||||
"recommended_model": first_model_id,
|
||||
"reason": "No specific match found, using default model",
|
||||
"alternatives": [],
|
||||
}
|
||||
else:
|
||||
raise ValueError("No models available")
|
||||
|
||||
# Apply preferences
|
||||
prioritize_cost = preferences and preferences.get("prioritize_cost", False)
|
||||
prioritize_quality = preferences and preferences.get("prioritize_quality", False)
|
||||
|
||||
# Score models
|
||||
scored_models = []
|
||||
for model_id, model_info in available_models.items():
|
||||
score = 0
|
||||
cost = model_info.get("cost", 0.02)
|
||||
tier = model_info.get("tier", "mid")
|
||||
max_res = model_info.get("max_resolution", (2048, 2048))
|
||||
max_resolution = max(max_res[0], max_res[1])
|
||||
|
||||
# Cost scoring (lower is better)
|
||||
if prioritize_cost:
|
||||
score += (1.0 / cost) * 100 # Invert cost for scoring
|
||||
else:
|
||||
score += (1.0 / cost) * 50 # Less weight if not prioritizing
|
||||
|
||||
# Quality scoring (higher resolution = better)
|
||||
if prioritize_quality:
|
||||
score += max_resolution / 10 # Higher weight for quality
|
||||
else:
|
||||
score += max_resolution / 20 # Lower weight
|
||||
|
||||
# Tier preference based on user tier
|
||||
if user_tier == "free":
|
||||
if tier == "budget":
|
||||
score += 50
|
||||
elif tier == "mid":
|
||||
score += 20
|
||||
elif user_tier in ["pro", "enterprise"]:
|
||||
if tier == "premium":
|
||||
score += 50
|
||||
elif tier == "mid":
|
||||
score += 30
|
||||
|
||||
scored_models.append((model_id, model_info, score))
|
||||
|
||||
# Sort by score (highest first)
|
||||
scored_models.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
# Get recommended model
|
||||
recommended_id, recommended_info, recommended_score = scored_models[0]
|
||||
|
||||
# Build reason
|
||||
reasons = []
|
||||
if prioritize_cost:
|
||||
reasons.append("Lowest cost option")
|
||||
if prioritize_quality:
|
||||
reasons.append("Best quality")
|
||||
if image_resolution:
|
||||
reasons.append(f"Supports {image_resolution.get('width')}×{image_resolution.get('height')} resolution")
|
||||
if user_tier == "free" and recommended_info.get("tier") == "budget":
|
||||
reasons.append("Budget-friendly for free tier")
|
||||
|
||||
reason = ", ".join(reasons) if reasons else "Best match for your requirements"
|
||||
|
||||
# Get alternatives (top 2-3)
|
||||
alternatives = []
|
||||
for model_id, model_info, score in scored_models[1:4]:
|
||||
alt_reason = f"Alternative: {model_info.get('tier', 'mid').title()} tier"
|
||||
if model_info.get("cost", 0) < recommended_info.get("cost", 0):
|
||||
alt_reason += ", lower cost"
|
||||
elif model_info.get("cost", 0) > recommended_info.get("cost", 0):
|
||||
alt_reason += ", higher quality"
|
||||
alternatives.append({
|
||||
"model_id": model_id,
|
||||
"name": model_info.get("name", model_id),
|
||||
"cost": model_info.get("cost", 0.02),
|
||||
"reason": alt_reason,
|
||||
})
|
||||
|
||||
return {
|
||||
"recommended_model": recommended_id,
|
||||
"reason": reason,
|
||||
"alternatives": alternatives,
|
||||
}
|
||||
|
||||
def _get_use_cases_for_model(self, model_id: str, model_info: Dict[str, Any]) -> list:
|
||||
"""Get use cases for a model based on its capabilities."""
|
||||
use_cases_map = {
|
||||
"general_edit": ["Quick edits", "Style changes", "Background replacement"],
|
||||
"style_transfer": ["Apply artistic styles", "Style transformations"],
|
||||
"text_edit": ["Add text to images", "Edit text in images"],
|
||||
"multi_image": ["Batch editing", "Consistent character work"],
|
||||
"high_res": ["Professional work", "Print materials", "4K/8K editing"],
|
||||
"professional": ["Marketing campaigns", "Brand assets"],
|
||||
"typography": ["Text-heavy edits", "Typography generation"],
|
||||
"portrait_retouching": ["Portrait edits", "Beauty retouching"],
|
||||
"fashion_edit": ["Fashion photography", "Outfit changes"],
|
||||
"product_edit": ["E-commerce", "Product photography"],
|
||||
}
|
||||
|
||||
capabilities = model_info.get("capabilities", [])
|
||||
use_cases = []
|
||||
for cap in capabilities:
|
||||
if cap in use_cases_map:
|
||||
use_cases.extend(use_cases_map[cap])
|
||||
|
||||
# Remove duplicates
|
||||
return list(set(use_cases)) if use_cases else ["General image editing"]
|
||||
|
||||
def _get_features_for_model(self, model_info: Dict[str, Any]) -> list:
|
||||
"""Get feature list for a model."""
|
||||
features = []
|
||||
|
||||
if model_info.get("supports_multi_image"):
|
||||
max_images = model_info.get("api_params", {}).get("max_images", 0)
|
||||
if max_images:
|
||||
features.append(f"Multi-image ({max_images} images)")
|
||||
else:
|
||||
features.append("Multi-image support")
|
||||
|
||||
if model_info.get("supports_controlnet"):
|
||||
features.append("ControlNet support")
|
||||
|
||||
languages = model_info.get("languages", [])
|
||||
if len(languages) > 1:
|
||||
features.append(f"Multilingual ({', '.join(languages)})")
|
||||
elif "multilingual" in languages:
|
||||
features.append("Multilingual support")
|
||||
|
||||
max_res = model_info.get("max_resolution", (2048, 2048))
|
||||
if max(max_res) >= 4096:
|
||||
features.append("4K/8K support")
|
||||
elif max(max_res) >= 2048:
|
||||
features.append("2K support")
|
||||
|
||||
api_params = model_info.get("api_params", {})
|
||||
if api_params.get("supports_guidance_scale"):
|
||||
features.append("Guidance scale control")
|
||||
|
||||
return features if features else ["Standard editing"]
|
||||
|
||||
async def process_edit(
|
||||
self,
|
||||
@@ -221,6 +465,9 @@ class EditStudioService:
|
||||
) -> Dict[str, Any]:
|
||||
"""Process edit request and return normalized response."""
|
||||
|
||||
# Pre-flight validation: Use specific validator for editing operations
|
||||
# Note: Editing uses validate_image_editing_operations (different from generation)
|
||||
# This is intentional as editing may have different subscription limits
|
||||
if user_id:
|
||||
from services.database import get_db
|
||||
from services.subscription import PricingService
|
||||
@@ -386,29 +633,109 @@ class EditStudioService:
|
||||
mask_bytes: Optional[bytes],
|
||||
user_id: Optional[str],
|
||||
) -> bytes:
|
||||
"""Execute Hugging Face powered general editing (synchronous API)."""
|
||||
"""Execute general editing - routes to WaveSpeed (unified entry) or HuggingFace (legacy).
|
||||
|
||||
If model is a WaveSpeed model (qwen-edit-plus, nano-banana-pro-edit-ultra, seedream-v4.5-edit),
|
||||
uses unified entry point. Otherwise falls back to HuggingFace for backward compatibility.
|
||||
"""
|
||||
if not request.prompt:
|
||||
raise ValueError("Prompt is required for general edits")
|
||||
|
||||
options = {
|
||||
"provider": request.provider or "huggingface",
|
||||
"model": request.model,
|
||||
"guidance_scale": request.guidance_scale,
|
||||
"steps": request.steps,
|
||||
"seed": request.seed,
|
||||
}
|
||||
|
||||
# huggingface edit is synchronous - run in thread
|
||||
result = await asyncio.to_thread(
|
||||
huggingface_edit_image,
|
||||
image_bytes,
|
||||
request.prompt,
|
||||
options,
|
||||
user_id,
|
||||
mask_bytes, # Optional mask for selective editing
|
||||
# Check if model is a WaveSpeed editing model
|
||||
from services.llm_providers.image_generation.wavespeed_edit_provider import WaveSpeedEditProvider
|
||||
provider = WaveSpeedEditProvider()
|
||||
wavespeed_models = set(provider.get_available_models().keys())
|
||||
|
||||
# Also check if provider is explicitly set to "wavespeed"
|
||||
is_wavespeed = (
|
||||
request.provider == "wavespeed" or
|
||||
(request.model and request.model in wavespeed_models)
|
||||
)
|
||||
|
||||
# Auto-detect: If no model specified and operation is general_edit, recommend one
|
||||
if not request.model and not is_wavespeed and request.operation == "general_edit":
|
||||
# Auto-select recommended model
|
||||
try:
|
||||
# Get image dimensions for recommendation
|
||||
with Image.open(io.BytesIO(image_bytes)) as img:
|
||||
image_resolution = {"width": img.width, "height": img.height}
|
||||
|
||||
recommendation = self.recommend_model(
|
||||
operation=request.operation,
|
||||
image_resolution=image_resolution,
|
||||
preferences={"prioritize_cost": True}, # Default to cost-optimized
|
||||
)
|
||||
recommended_model = recommendation.get("recommended_model")
|
||||
if recommended_model and recommended_model in wavespeed_models:
|
||||
logger.info(f"[Edit Studio] Auto-selected model: {recommended_model} (reason: {recommendation.get('reason')})")
|
||||
request.model = recommended_model
|
||||
is_wavespeed = True
|
||||
except Exception as e:
|
||||
logger.warning(f"[Edit Studio] Auto-detection failed: {e}, falling back to HuggingFace")
|
||||
|
||||
if is_wavespeed:
|
||||
# Use unified entry point for WaveSpeed models
|
||||
logger.info(f"[Edit Studio] Using WaveSpeed unified entry for model={request.model}")
|
||||
|
||||
# Convert image bytes to base64
|
||||
import base64
|
||||
image_base64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||
|
||||
# Prepare options for unified entry point
|
||||
edit_options = {
|
||||
"mask_base64": None,
|
||||
"negative_prompt": request.negative_prompt,
|
||||
"width": None, # Will be determined from image if needed
|
||||
"height": None,
|
||||
"guidance_scale": request.guidance_scale,
|
||||
"steps": request.steps,
|
||||
"seed": request.seed,
|
||||
}
|
||||
|
||||
# Add mask if provided
|
||||
if mask_bytes:
|
||||
edit_options["mask_base64"] = base64.b64encode(mask_bytes).decode("utf-8")
|
||||
|
||||
# Extract dimensions from image if needed
|
||||
with Image.open(io.BytesIO(image_bytes)) as img:
|
||||
edit_options["width"] = img.width
|
||||
edit_options["height"] = img.height
|
||||
|
||||
# Call unified entry point (synchronous, so run in thread)
|
||||
result = await asyncio.to_thread(
|
||||
generate_image_edit,
|
||||
image_base64=image_base64,
|
||||
prompt=request.prompt,
|
||||
operation=request.operation or "general_edit",
|
||||
model=request.model, # Will auto-select if None
|
||||
options=edit_options,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result.image_bytes
|
||||
else:
|
||||
# Fall back to HuggingFace for backward compatibility
|
||||
logger.info("[Edit Studio] Using HuggingFace (legacy) for general edit")
|
||||
|
||||
options = {
|
||||
"provider": request.provider or "huggingface",
|
||||
"model": request.model,
|
||||
"guidance_scale": request.guidance_scale,
|
||||
"steps": request.steps,
|
||||
"seed": request.seed,
|
||||
}
|
||||
|
||||
return result.image_bytes
|
||||
# huggingface edit is synchronous - run in thread
|
||||
result = await asyncio.to_thread(
|
||||
huggingface_edit_image,
|
||||
image_bytes,
|
||||
request.prompt,
|
||||
options,
|
||||
user_id,
|
||||
mask_bytes, # Optional mask for selective editing
|
||||
)
|
||||
|
||||
return result.image_bytes
|
||||
|
||||
@staticmethod
|
||||
def _extract_image_bytes(result: Any) -> bytes:
|
||||
|
||||
266
backend/services/image_studio/face_swap_service.py
Normal file
266
backend/services/image_studio/face_swap_service.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Face Swap Studio service for AI-powered face swapping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
from PIL import Image
|
||||
|
||||
from services.llm_providers.main_image_generation import generate_face_swap
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
logger = get_service_logger("image_studio.face_swap")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FaceSwapStudioRequest:
|
||||
"""Request model for face swap operations."""
|
||||
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 FaceSwapService:
|
||||
"""Service for face swap operations."""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_available_models(
|
||||
self,
|
||||
tier: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get available WaveSpeed face swap models.
|
||||
|
||||
Args:
|
||||
tier: Filter by tier ("budget", "mid", "premium")
|
||||
|
||||
Returns:
|
||||
Dictionary with models and metadata
|
||||
"""
|
||||
from services.llm_providers.image_generation.wavespeed_face_swap_provider import WaveSpeedFaceSwapProvider
|
||||
|
||||
provider = WaveSpeedFaceSwapProvider()
|
||||
all_models = provider.get_available_models()
|
||||
|
||||
# Filter by tier if specified
|
||||
if tier:
|
||||
filtered = provider.get_models_by_tier(tier)
|
||||
all_models = {k: v for k, v in all_models.items() if k in filtered}
|
||||
|
||||
# Format for API response
|
||||
models_list = []
|
||||
for model_id, model_info in all_models.items():
|
||||
models_list.append({
|
||||
"id": model_id,
|
||||
"name": model_info.get("name", model_id),
|
||||
"description": model_info.get("description", ""),
|
||||
"cost": model_info.get("cost", 0.025),
|
||||
"tier": model_info.get("tier", "mid"),
|
||||
"capabilities": model_info.get("capabilities", []),
|
||||
"use_cases": self._get_use_cases_for_model(model_id, model_info),
|
||||
"features": model_info.get("features", []),
|
||||
"max_faces": model_info.get("max_faces", 1),
|
||||
})
|
||||
|
||||
return {
|
||||
"models": models_list,
|
||||
"total": len(models_list),
|
||||
}
|
||||
|
||||
def recommend_model(
|
||||
self,
|
||||
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,
|
||||
) -> Dict[str, Any]:
|
||||
"""Recommend best model for face swap.
|
||||
|
||||
Args:
|
||||
base_image_resolution: Dict with "width" and "height" of base image
|
||||
face_image_resolution: Dict with "width" and "height" of face image
|
||||
user_tier: User subscription tier ("free", "pro", "enterprise")
|
||||
preferences: Dict with "prioritize_cost" or "prioritize_quality"
|
||||
|
||||
Returns:
|
||||
Dictionary with recommended model and alternatives
|
||||
"""
|
||||
from services.llm_providers.image_generation.wavespeed_face_swap_provider import WaveSpeedFaceSwapProvider
|
||||
|
||||
provider = WaveSpeedFaceSwapProvider()
|
||||
available_models = provider.get_available_models()
|
||||
|
||||
if not available_models:
|
||||
raise ValueError("No models available")
|
||||
|
||||
# Apply preferences
|
||||
prioritize_cost = preferences and preferences.get("prioritize_cost", False)
|
||||
prioritize_quality = preferences and preferences.get("prioritize_quality", False)
|
||||
|
||||
# Score models
|
||||
scored_models = []
|
||||
for model_id, model_info in available_models.items():
|
||||
score = 0
|
||||
cost = model_info.get("cost", 0.025)
|
||||
tier = model_info.get("tier", "mid")
|
||||
|
||||
# Cost scoring (lower is better)
|
||||
if prioritize_cost:
|
||||
score += (1.0 / cost) * 100
|
||||
else:
|
||||
score += (1.0 / cost) * 50
|
||||
|
||||
# Quality scoring (higher cost = better quality for face swap)
|
||||
if prioritize_quality:
|
||||
score += cost * 20
|
||||
else:
|
||||
score += cost * 10
|
||||
|
||||
# Tier preference based on user tier
|
||||
if user_tier == "free":
|
||||
if tier == "budget":
|
||||
score += 50
|
||||
elif tier == "mid":
|
||||
score += 20
|
||||
elif user_tier in ["pro", "enterprise"]:
|
||||
if tier == "premium":
|
||||
score += 50
|
||||
elif tier == "mid":
|
||||
score += 30
|
||||
|
||||
scored_models.append((model_id, model_info, score))
|
||||
|
||||
# Sort by score (highest first)
|
||||
scored_models.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
# Get recommended model
|
||||
recommended_id, recommended_info, recommended_score = scored_models[0]
|
||||
|
||||
# Build reason
|
||||
reasons = []
|
||||
if prioritize_cost:
|
||||
reasons.append("Lowest cost option")
|
||||
if prioritize_quality:
|
||||
reasons.append("Best quality")
|
||||
if user_tier == "free" and recommended_info.get("tier") == "budget":
|
||||
reasons.append("Budget-friendly for free tier")
|
||||
|
||||
reason = ", ".join(reasons) if reasons else "Best match for your requirements"
|
||||
|
||||
# Get alternatives (top 2-3)
|
||||
alternatives = []
|
||||
for model_id, model_info, score in scored_models[1:4]:
|
||||
alt_reason = f"Alternative: {model_info.get('tier', 'mid').title()} tier"
|
||||
if model_info.get("cost", 0) < recommended_info.get("cost", 0):
|
||||
alt_reason += ", lower cost"
|
||||
elif model_info.get("cost", 0) > recommended_info.get("cost", 0):
|
||||
alt_reason += ", higher quality"
|
||||
alternatives.append({
|
||||
"model_id": model_id,
|
||||
"name": model_info.get("name", model_id),
|
||||
"cost": model_info.get("cost", 0.025),
|
||||
"reason": alt_reason,
|
||||
})
|
||||
|
||||
return {
|
||||
"recommended_model": recommended_id,
|
||||
"reason": reason,
|
||||
"alternatives": alternatives,
|
||||
}
|
||||
|
||||
def _get_use_cases_for_model(self, model_id: str, model_info: Dict[str, Any]) -> list:
|
||||
"""Get use cases for a model based on its capabilities."""
|
||||
use_cases_map = {
|
||||
"face_swap": ["Portrait editing", "Fun swaps", "Social media"],
|
||||
"head_swap": ["Casting and concept design", "Privacy and anonymization", "Photo exploration"],
|
||||
"full_head_replacement": ["Full head replacement", "Hair included", "Casting mockups"],
|
||||
"realistic_blending": ["Professional work", "Marketing", "Entertainment"],
|
||||
"multi_face": ["Group photos", "Family photos", "Team photos", "Creative projects", "Content creation"],
|
||||
"face_enhancement": ["High-quality results", "Professional work", "Marketing campaigns"],
|
||||
"identity_preservation": ["Character consistency", "Brand identity"],
|
||||
}
|
||||
|
||||
capabilities = model_info.get("capabilities", [])
|
||||
use_cases = []
|
||||
for cap in capabilities:
|
||||
if cap in use_cases_map:
|
||||
use_cases.extend(use_cases_map[cap])
|
||||
|
||||
return list(set(use_cases)) if use_cases else ["General face swapping"]
|
||||
|
||||
async def process_face_swap(
|
||||
self,
|
||||
request: FaceSwapStudioRequest,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Process face swap request.
|
||||
|
||||
Args:
|
||||
request: Face swap request
|
||||
user_id: User ID for tracking
|
||||
|
||||
Returns:
|
||||
Dictionary with result image and metadata
|
||||
"""
|
||||
# Auto-detect model if not specified
|
||||
selected_model = request.model
|
||||
if not selected_model:
|
||||
try:
|
||||
# Get image dimensions for recommendation
|
||||
base_img = Image.open(io.BytesIO(base64.b64decode(request.base_image_base64.split(",", 1)[1] if "," in request.base_image_base64 else request.base_image_base64)))
|
||||
face_img = Image.open(io.BytesIO(base64.b64decode(request.face_image_base64.split(",", 1)[1] if "," in request.face_image_base64 else request.face_image_base64)))
|
||||
|
||||
base_resolution = {"width": base_img.width, "height": base_img.height}
|
||||
face_resolution = {"width": face_img.width, "height": face_img.height}
|
||||
|
||||
recommendation = self.recommend_model(
|
||||
base_image_resolution=base_resolution,
|
||||
face_image_resolution=face_resolution,
|
||||
preferences={"prioritize_cost": True},
|
||||
)
|
||||
selected_model = recommendation.get("recommended_model")
|
||||
logger.info(f"[Face Swap] Auto-selected model: {selected_model} (reason: {recommendation.get('reason')})")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Face Swap] Auto-detection failed: {e}, using default model")
|
||||
# Use first available model as fallback
|
||||
from services.llm_providers.image_generation.wavespeed_face_swap_provider import WaveSpeedFaceSwapProvider
|
||||
provider = WaveSpeedFaceSwapProvider()
|
||||
all_models = provider.get_available_models()
|
||||
if all_models:
|
||||
selected_model = list(all_models.keys())[0]
|
||||
|
||||
# Prepare options
|
||||
options = request.options or {}
|
||||
if request.target_face_index is not None:
|
||||
options["target_face_index"] = request.target_face_index
|
||||
if request.target_gender:
|
||||
options["target_gender"] = request.target_gender
|
||||
|
||||
# Call unified entry point
|
||||
result = generate_face_swap(
|
||||
base_image_base64=request.base_image_base64,
|
||||
face_image_base64=request.face_image_base64,
|
||||
model=selected_model,
|
||||
options=options,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Convert result to base64
|
||||
result_base64 = base64.b64encode(result.image_bytes).decode("utf-8")
|
||||
result_data_url = f"data:image/png;base64,{result_base64}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"image_base64": result_data_url,
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"provider": result.provider,
|
||||
"model": result.model,
|
||||
"metadata": result.metadata or {},
|
||||
}
|
||||
403
backend/services/image_studio/format_converter_service.py
Normal file
403
backend/services/image_studio/format_converter_service.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""Image Format Converter Service for converting between image formats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PIL import Image, ImageCms
|
||||
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
|
||||
logger = get_service_logger("image_studio.format_converter")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormatConversionRequest:
|
||||
"""Request model for format conversion."""
|
||||
image_base64: str
|
||||
target_format: str # png, jpeg, jpg, webp, gif, bmp, tiff
|
||||
preserve_transparency: bool = True
|
||||
quality: Optional[int] = None # For lossy formats (1-100)
|
||||
color_space: Optional[str] = None # sRGB, Adobe RGB, etc.
|
||||
strip_metadata: bool = False # Keep metadata by default for conversion
|
||||
optimize: bool = True
|
||||
progressive: bool = True # For JPEG
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormatConversionResult:
|
||||
"""Result of format conversion."""
|
||||
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 ImageFormatConverterService:
|
||||
"""Service for converting images between formats."""
|
||||
|
||||
SUPPORTED_FORMATS = {
|
||||
"png": {
|
||||
"name": "PNG",
|
||||
"description": "Lossless format with transparency support",
|
||||
"supports_transparency": True,
|
||||
"supports_lossy": False,
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
"jpeg": {
|
||||
"name": "JPEG",
|
||||
"description": "Lossy format, best for photos",
|
||||
"supports_transparency": False,
|
||||
"supports_lossy": True,
|
||||
"mime_type": "image/jpeg",
|
||||
},
|
||||
"jpg": {
|
||||
"name": "JPEG",
|
||||
"description": "Lossy format, best for photos",
|
||||
"supports_transparency": False,
|
||||
"supports_lossy": True,
|
||||
"mime_type": "image/jpeg",
|
||||
},
|
||||
"webp": {
|
||||
"name": "WebP",
|
||||
"description": "Modern format with excellent compression",
|
||||
"supports_transparency": True,
|
||||
"supports_lossy": True,
|
||||
"mime_type": "image/webp",
|
||||
},
|
||||
"gif": {
|
||||
"name": "GIF",
|
||||
"description": "Supports animation and transparency",
|
||||
"supports_transparency": True,
|
||||
"supports_lossy": False,
|
||||
"mime_type": "image/gif",
|
||||
},
|
||||
"bmp": {
|
||||
"name": "BMP",
|
||||
"description": "Uncompressed bitmap format",
|
||||
"supports_transparency": False,
|
||||
"supports_lossy": False,
|
||||
"mime_type": "image/bmp",
|
||||
},
|
||||
"tiff": {
|
||||
"name": "TIFF",
|
||||
"description": "High-quality format for print",
|
||||
"supports_transparency": True,
|
||||
"supports_lossy": False,
|
||||
"mime_type": "image/tiff",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
logger.info("[Format Converter] ImageFormatConverterService initialized")
|
||||
|
||||
def _decode_image(self, image_base64: str) -> tuple[Image.Image, int, str]:
|
||||
"""Decode base64 image and return PIL Image, size, and format."""
|
||||
# Handle data URL format
|
||||
if "," in image_base64:
|
||||
image_base64 = image_base64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(image_base64)
|
||||
original_size = len(image_bytes)
|
||||
|
||||
image = Image.open(io.BytesIO(image_bytes))
|
||||
original_format = image.format.lower() if image.format else "unknown"
|
||||
|
||||
return image, original_size, original_format
|
||||
|
||||
def _strip_exif(self, image: Image.Image) -> Image.Image:
|
||||
"""Remove EXIF metadata from image."""
|
||||
data = list(image.getdata())
|
||||
image_without_exif = Image.new(image.mode, image.size)
|
||||
image_without_exif.putdata(data)
|
||||
return image_without_exif
|
||||
|
||||
def _convert_color_space(
|
||||
self,
|
||||
image: Image.Image,
|
||||
target_color_space: str,
|
||||
) -> Image.Image:
|
||||
"""Convert image color space."""
|
||||
try:
|
||||
# Get current color space
|
||||
if hasattr(image, 'info') and 'icc_profile' in image.info:
|
||||
# Image has ICC profile
|
||||
try:
|
||||
src_profile = ImageCms.ImageCmsProfile(io.BytesIO(image.info['icc_profile']))
|
||||
if target_color_space.lower() == "srgb":
|
||||
dst_profile = ImageCms.createProfile("sRGB")
|
||||
elif target_color_space.lower() == "adobe rgb":
|
||||
dst_profile = ImageCms.createProfile("Adobe RGB")
|
||||
else:
|
||||
return image # Unknown color space
|
||||
|
||||
transform = ImageCms.ImageCmsTransform(src_profile, dst_profile, image.mode, image.mode)
|
||||
image = ImageCms.applyTransform(image, transform)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Format Converter] Color space conversion failed: {e}")
|
||||
else:
|
||||
# No ICC profile, assume sRGB
|
||||
logger.info("[Format Converter] No ICC profile found, assuming sRGB")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Format Converter] Color space conversion error: {e}")
|
||||
|
||||
return image
|
||||
|
||||
def _convert_image(
|
||||
self,
|
||||
image: Image.Image,
|
||||
target_format: str,
|
||||
quality: Optional[int],
|
||||
preserve_transparency: bool,
|
||||
optimize: bool,
|
||||
progressive: bool,
|
||||
) -> bytes:
|
||||
"""Convert image to target format."""
|
||||
buffer = io.BytesIO()
|
||||
format_lower = target_format.lower()
|
||||
|
||||
# Handle format-specific conversions
|
||||
save_kwargs: Dict[str, Any] = {}
|
||||
|
||||
# Check if source has transparency and target doesn't support it
|
||||
has_transparency = image.mode in ("RGBA", "LA", "P") and (
|
||||
"transparency" in image.info or image.mode == "RGBA"
|
||||
)
|
||||
|
||||
if format_lower in ["jpeg", "jpg"]:
|
||||
# JPEG doesn't support transparency
|
||||
if has_transparency and preserve_transparency:
|
||||
# Convert to RGB, losing transparency
|
||||
if image.mode in ("RGBA", "LA"):
|
||||
# Create white background
|
||||
rgb_image = Image.new("RGB", image.size, (255, 255, 255))
|
||||
if image.mode == "RGBA":
|
||||
rgb_image.paste(image, mask=image.split()[3]) # Use alpha channel as mask
|
||||
else:
|
||||
rgb_image.paste(image)
|
||||
image = rgb_image
|
||||
elif image.mode == "P":
|
||||
image = image.convert("RGB")
|
||||
else:
|
||||
image = image.convert("RGB")
|
||||
|
||||
save_kwargs["format"] = "JPEG"
|
||||
if quality:
|
||||
save_kwargs["quality"] = quality
|
||||
else:
|
||||
save_kwargs["quality"] = 95 # Default high quality
|
||||
save_kwargs["optimize"] = optimize
|
||||
if progressive:
|
||||
save_kwargs["progressive"] = True
|
||||
|
||||
elif format_lower == "png":
|
||||
save_kwargs["format"] = "PNG"
|
||||
save_kwargs["optimize"] = optimize
|
||||
# PNG compression level (0-9)
|
||||
if quality:
|
||||
compress_level = max(0, min(9, (100 - quality) // 11))
|
||||
save_kwargs["compress_level"] = compress_level
|
||||
else:
|
||||
save_kwargs["compress_level"] = 6 # Default
|
||||
|
||||
elif format_lower == "webp":
|
||||
save_kwargs["format"] = "WEBP"
|
||||
if quality:
|
||||
save_kwargs["quality"] = quality
|
||||
else:
|
||||
save_kwargs["quality"] = 80 # Default
|
||||
save_kwargs["method"] = 6 # Best compression
|
||||
if preserve_transparency and has_transparency:
|
||||
# WebP supports transparency
|
||||
if image.mode not in ("RGBA", "LA"):
|
||||
image = image.convert("RGBA")
|
||||
|
||||
elif format_lower == "gif":
|
||||
save_kwargs["format"] = "GIF"
|
||||
# GIF conversion
|
||||
if image.mode != "P":
|
||||
# Convert to palette mode for GIF
|
||||
image = image.convert("P", palette=Image.ADAPTIVE)
|
||||
save_kwargs["optimize"] = optimize
|
||||
if preserve_transparency and has_transparency:
|
||||
save_kwargs["transparency"] = 255 # Preserve transparency
|
||||
|
||||
elif format_lower == "bmp":
|
||||
save_kwargs["format"] = "BMP"
|
||||
if image.mode in ("RGBA", "LA", "P") and has_transparency:
|
||||
# BMP doesn't support transparency, convert to RGB
|
||||
if image.mode == "RGBA":
|
||||
rgb_image = Image.new("RGB", image.size, (255, 255, 255))
|
||||
rgb_image.paste(image, mask=image.split()[3])
|
||||
image = rgb_image
|
||||
else:
|
||||
image = image.convert("RGB")
|
||||
|
||||
elif format_lower == "tiff":
|
||||
save_kwargs["format"] = "TIFF"
|
||||
save_kwargs["compression"] = "tiff_lzw" # Lossless compression
|
||||
if preserve_transparency and has_transparency:
|
||||
# TIFF supports transparency
|
||||
if image.mode not in ("RGBA", "LA"):
|
||||
image = image.convert("RGBA")
|
||||
else:
|
||||
raise ValueError(f"Unsupported target format: {target_format}")
|
||||
|
||||
image.save(buffer, **save_kwargs)
|
||||
return buffer.getvalue()
|
||||
|
||||
async def convert(
|
||||
self,
|
||||
request: FormatConversionRequest,
|
||||
user_id: Optional[str] = None,
|
||||
) -> FormatConversionResult:
|
||||
"""Convert an image to target format."""
|
||||
logger.info(f"[Format Converter] Processing conversion request for user: {user_id}")
|
||||
|
||||
try:
|
||||
# Decode image
|
||||
image, original_size, original_format = self._decode_image(request.image_base64)
|
||||
original_size_kb = original_size / 1024
|
||||
|
||||
logger.info(f"[Format Converter] Original: {original_format}, Target: {request.target_format}, Size: {original_size_kb:.2f} KB")
|
||||
|
||||
# Validate target format
|
||||
format_lower = request.target_format.lower()
|
||||
if format_lower not in self.SUPPORTED_FORMATS:
|
||||
raise ValueError(f"Unsupported format: {request.target_format}. Supported: {list(self.SUPPORTED_FORMATS.keys())}")
|
||||
|
||||
# Check transparency preservation
|
||||
has_transparency = image.mode in ("RGBA", "LA", "P") and (
|
||||
"transparency" in image.info or image.mode == "RGBA"
|
||||
)
|
||||
target_supports_transparency = self.SUPPORTED_FORMATS[format_lower]["supports_transparency"]
|
||||
transparency_preserved = (
|
||||
has_transparency and
|
||||
target_supports_transparency and
|
||||
request.preserve_transparency
|
||||
)
|
||||
|
||||
# Color space conversion
|
||||
if request.color_space:
|
||||
image = self._convert_color_space(image, request.color_space)
|
||||
|
||||
# Strip metadata if requested
|
||||
metadata_preserved = not request.strip_metadata
|
||||
if request.strip_metadata:
|
||||
image = self._strip_exif(image)
|
||||
|
||||
# Convert format
|
||||
converted_bytes = self._convert_image(
|
||||
image,
|
||||
format_lower,
|
||||
request.quality,
|
||||
request.preserve_transparency,
|
||||
request.optimize,
|
||||
request.progressive,
|
||||
)
|
||||
|
||||
converted_size_kb = len(converted_bytes) / 1024
|
||||
|
||||
# Encode result
|
||||
mime_type = self.SUPPORTED_FORMATS[format_lower]["mime_type"]
|
||||
result_base64 = f"data:{mime_type};base64,{base64.b64encode(converted_bytes).decode()}"
|
||||
|
||||
logger.info(f"[Format Converter] Converted: {original_size_kb:.2f}KB → {converted_size_kb:.2f}KB")
|
||||
|
||||
return FormatConversionResult(
|
||||
success=True,
|
||||
image_base64=result_base64,
|
||||
original_format=original_format,
|
||||
target_format=format_lower,
|
||||
original_size_kb=round(original_size_kb, 2),
|
||||
converted_size_kb=round(converted_size_kb, 2),
|
||||
width=image.width,
|
||||
height=image.height,
|
||||
transparency_preserved=transparency_preserved,
|
||||
metadata_preserved=metadata_preserved,
|
||||
color_space=request.color_space,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Format Converter] Failed to convert image: {e}")
|
||||
raise
|
||||
|
||||
async def convert_batch(
|
||||
self,
|
||||
requests: List[FormatConversionRequest],
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[FormatConversionResult]:
|
||||
"""Convert multiple images."""
|
||||
logger.info(f"[Format Converter] Processing batch of {len(requests)} images for user: {user_id}")
|
||||
|
||||
results = []
|
||||
for i, request in enumerate(requests):
|
||||
try:
|
||||
result = await self.convert(request, user_id)
|
||||
results.append(result)
|
||||
logger.info(f"[Format Converter] Batch item {i+1}/{len(requests)} complete")
|
||||
except Exception as e:
|
||||
logger.error(f"[Format Converter] Batch item {i+1} failed: {e}")
|
||||
results.append(FormatConversionResult(
|
||||
success=False,
|
||||
image_base64="",
|
||||
original_format="",
|
||||
target_format="",
|
||||
original_size_kb=0,
|
||||
converted_size_kb=0,
|
||||
width=0,
|
||||
height=0,
|
||||
transparency_preserved=False,
|
||||
metadata_preserved=False,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
def get_supported_formats(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of supported formats with details."""
|
||||
return [
|
||||
{
|
||||
"id": fmt_id,
|
||||
"name": fmt_info["name"],
|
||||
"description": fmt_info["description"],
|
||||
"supports_transparency": fmt_info["supports_transparency"],
|
||||
"supports_lossy": fmt_info["supports_lossy"],
|
||||
"mime_type": fmt_info["mime_type"],
|
||||
}
|
||||
for fmt_id, fmt_info in self.SUPPORTED_FORMATS.items()
|
||||
]
|
||||
|
||||
def get_format_recommendations(self, source_format: str) -> List[Dict[str, Any]]:
|
||||
"""Get format recommendations based on source format."""
|
||||
recommendations = {
|
||||
"png": [
|
||||
{"format": "webp", "reason": "60% smaller file size, maintains transparency"},
|
||||
{"format": "jpeg", "reason": "Best for photos, smaller file size"},
|
||||
],
|
||||
"jpeg": [
|
||||
{"format": "webp", "reason": "25-34% smaller with similar quality"},
|
||||
{"format": "png", "reason": "Lossless, supports transparency"},
|
||||
],
|
||||
"jpg": [
|
||||
{"format": "webp", "reason": "25-34% smaller with similar quality"},
|
||||
{"format": "png", "reason": "Lossless, supports transparency"},
|
||||
],
|
||||
"webp": [
|
||||
{"format": "png", "reason": "Better compatibility, lossless"},
|
||||
{"format": "jpeg", "reason": "Universal compatibility"},
|
||||
],
|
||||
}
|
||||
|
||||
source_lower = source_format.lower()
|
||||
return recommendations.get(source_lower, [])
|
||||
@@ -7,6 +7,9 @@ from .edit_service import EditStudioService, EditStudioRequest
|
||||
from .upscale_service import UpscaleStudioService, UpscaleStudioRequest
|
||||
from .control_service import ControlStudioService, ControlStudioRequest
|
||||
from .social_optimizer_service import SocialOptimizerService, SocialOptimizerRequest
|
||||
from .face_swap_service import FaceSwapService, FaceSwapStudioRequest
|
||||
from .compression_service import ImageCompressionService, CompressionRequest, CompressionResult
|
||||
from .format_converter_service import ImageFormatConverterService, FormatConversionRequest, FormatConversionResult
|
||||
from .transform_service import (
|
||||
TransformStudioService,
|
||||
TransformImageToVideoRequest,
|
||||
@@ -29,6 +32,9 @@ class ImageStudioManager:
|
||||
self.upscale_service = UpscaleStudioService()
|
||||
self.control_service = ControlStudioService()
|
||||
self.social_optimizer_service = SocialOptimizerService()
|
||||
self.face_swap_service = FaceSwapService()
|
||||
self.compression_service = ImageCompressionService()
|
||||
self.format_converter_service = ImageFormatConverterService()
|
||||
self.transform_service = TransformStudioService()
|
||||
logger.info("[Image Studio Manager] Initialized successfully")
|
||||
|
||||
@@ -69,6 +75,99 @@ class ImageStudioManager:
|
||||
def get_edit_operations(self) -> Dict[str, Any]:
|
||||
"""Expose edit operations for UI."""
|
||||
return self.edit_service.list_operations()
|
||||
|
||||
def get_edit_models(
|
||||
self,
|
||||
operation: Optional[str] = None,
|
||||
tier: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get available editing models.
|
||||
|
||||
Args:
|
||||
operation: Filter by operation type
|
||||
tier: Filter by tier (budget, mid, premium)
|
||||
|
||||
Returns:
|
||||
Dictionary with models and metadata
|
||||
"""
|
||||
return self.edit_service.get_available_models(operation=operation, tier=tier)
|
||||
|
||||
def recommend_edit_model(
|
||||
self,
|
||||
operation: str,
|
||||
image_resolution: Optional[Dict[str, int]] = None,
|
||||
user_tier: Optional[str] = None,
|
||||
preferences: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Recommend best editing model for given context.
|
||||
|
||||
Args:
|
||||
operation: Operation type
|
||||
image_resolution: Image dimensions
|
||||
user_tier: User subscription tier
|
||||
preferences: User preferences (prioritize_cost, prioritize_quality)
|
||||
|
||||
Returns:
|
||||
Dictionary with recommended model and alternatives
|
||||
"""
|
||||
return self.edit_service.recommend_model(
|
||||
operation=operation,
|
||||
image_resolution=image_resolution,
|
||||
user_tier=user_tier,
|
||||
preferences=preferences,
|
||||
)
|
||||
|
||||
# ====================
|
||||
# FACE SWAP STUDIO
|
||||
# ====================
|
||||
|
||||
async def face_swap(
|
||||
self,
|
||||
request: FaceSwapStudioRequest,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run Face Swap Studio operations."""
|
||||
logger.info("[Image Studio] Face swap request from user: %s", user_id)
|
||||
return await self.face_swap_service.process_face_swap(request, user_id=user_id)
|
||||
|
||||
def get_face_swap_models(
|
||||
self,
|
||||
tier: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get available face swap models.
|
||||
|
||||
Args:
|
||||
tier: Filter by tier (budget, mid, premium)
|
||||
|
||||
Returns:
|
||||
Dictionary with models and metadata
|
||||
"""
|
||||
return self.face_swap_service.get_available_models(tier=tier)
|
||||
|
||||
def recommend_face_swap_model(
|
||||
self,
|
||||
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,
|
||||
) -> Dict[str, Any]:
|
||||
"""Recommend best face swap model for given context.
|
||||
|
||||
Args:
|
||||
base_image_resolution: Base image dimensions
|
||||
face_image_resolution: Face image dimensions
|
||||
user_tier: User subscription tier
|
||||
preferences: User preferences (prioritize_cost, prioritize_quality)
|
||||
|
||||
Returns:
|
||||
Dictionary with recommended model and alternatives
|
||||
"""
|
||||
return self.face_swap_service.recommend_model(
|
||||
base_image_resolution=base_image_resolution,
|
||||
face_image_resolution=face_image_resolution,
|
||||
user_tier=user_tier,
|
||||
preferences=preferences,
|
||||
)
|
||||
|
||||
# ====================
|
||||
# UPSCALE STUDIO
|
||||
@@ -377,3 +476,72 @@ class ImageStudioManager:
|
||||
"""Estimate cost for transform operation."""
|
||||
return self.transform_service.estimate_cost(operation, resolution, duration)
|
||||
|
||||
# ====================
|
||||
# COMPRESSION STUDIO
|
||||
# ====================
|
||||
|
||||
async def compress_image(
|
||||
self,
|
||||
request: CompressionRequest,
|
||||
user_id: Optional[str] = None,
|
||||
) -> CompressionResult:
|
||||
"""Compress an image with specified settings."""
|
||||
logger.info("[Image Studio] Compress image request from user: %s", user_id)
|
||||
return await self.compression_service.compress(request, user_id=user_id)
|
||||
|
||||
async def compress_batch(
|
||||
self,
|
||||
requests: List[CompressionRequest],
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[CompressionResult]:
|
||||
"""Compress multiple images."""
|
||||
logger.info("[Image Studio] Batch compress request (%d images) from user: %s", len(requests), user_id)
|
||||
return await self.compression_service.compress_batch(requests, user_id=user_id)
|
||||
|
||||
async def estimate_compression(
|
||||
self,
|
||||
image_base64: str,
|
||||
format: str = "jpeg",
|
||||
quality: int = 85,
|
||||
) -> Dict[str, Any]:
|
||||
"""Estimate compression results without compressing."""
|
||||
return await self.compression_service.estimate_compression(image_base64, format, quality)
|
||||
|
||||
def get_compression_formats(self) -> List[Dict[str, Any]]:
|
||||
"""Get supported compression formats."""
|
||||
return self.compression_service.get_supported_formats()
|
||||
|
||||
def get_compression_presets(self) -> List[Dict[str, Any]]:
|
||||
"""Get compression presets for common use cases."""
|
||||
return self.compression_service.get_presets()
|
||||
|
||||
# ====================
|
||||
# FORMAT CONVERTER
|
||||
# ====================
|
||||
|
||||
async def convert_format(
|
||||
self,
|
||||
request: FormatConversionRequest,
|
||||
user_id: Optional[str] = None,
|
||||
) -> FormatConversionResult:
|
||||
"""Convert an image to target format."""
|
||||
logger.info("[Image Studio] Convert format request from user: %s", user_id)
|
||||
return await self.format_converter_service.convert(request, user_id=user_id)
|
||||
|
||||
async def convert_format_batch(
|
||||
self,
|
||||
requests: List[FormatConversionRequest],
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[FormatConversionResult]:
|
||||
"""Convert multiple images."""
|
||||
logger.info("[Image Studio] Batch convert format request (%d images) from user: %s", len(requests), user_id)
|
||||
return await self.format_converter_service.convert_batch(requests, user_id=user_id)
|
||||
|
||||
def get_supported_formats(self) -> List[Dict[str, Any]]:
|
||||
"""Get supported conversion formats."""
|
||||
return self.format_converter_service.get_supported_formats()
|
||||
|
||||
def get_format_recommendations(self, source_format: str) -> List[Dict[str, Any]]:
|
||||
"""Get format recommendations based on source format."""
|
||||
return self.format_converter_service.get_format_recommendations(source_format)
|
||||
|
||||
|
||||
@@ -36,18 +36,16 @@ class UpscaleStudioService:
|
||||
request: UpscaleStudioRequest,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
# Pre-flight validation: Reuse unified helper
|
||||
# Note: Using image-generation validation since upscaling uses same subscription limits
|
||||
if user_id:
|
||||
from services.database import get_db
|
||||
from services.subscription import PricingService
|
||||
from services.subscription.preflight_validator import validate_image_upscale_operations
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
pricing_service = PricingService(db)
|
||||
logger.info("[Upscale Studio] 🛂 Running pre-flight validation for user %s", user_id)
|
||||
validate_image_upscale_operations(pricing_service=pricing_service, user_id=user_id)
|
||||
finally:
|
||||
db.close()
|
||||
from services.llm_providers.main_image_generation import _validate_image_operation
|
||||
_validate_image_operation(
|
||||
user_id=user_id,
|
||||
operation_type="image-upscale",
|
||||
num_operations=1,
|
||||
log_prefix="[Upscale Studio]"
|
||||
)
|
||||
|
||||
image_bytes = self._decode_base64(request.image_base64)
|
||||
if not image_bytes:
|
||||
|
||||
Reference in New Issue
Block a user