FastAPI async wrapper for Freepik cloud AI API supporting image generation (Mystic, Flux Dev/Pro, SeedReam), video generation (Kling, MiniMax, Seedance), image editing (upscale, relight, style transfer, expand, inpaint), and utilities (background removal, classifier, audio isolation). Includes async task tracking with polling, Docker containerization, and Gitea CI/CD workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
378 lines
10 KiB
Python
378 lines
10 KiB
Python
import asyncio
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_client: Optional[httpx.AsyncClient] = None
|
|
|
|
|
|
def get_client() -> httpx.AsyncClient:
|
|
if _client is None:
|
|
raise RuntimeError('Freepik client not initialized')
|
|
return _client
|
|
|
|
|
|
async def create_client() -> httpx.AsyncClient:
|
|
global _client
|
|
_client = httpx.AsyncClient(
|
|
base_url=settings.freepik_base_url,
|
|
headers={
|
|
'x-freepik-api-key': settings.freepik_api_key,
|
|
'Accept': 'application/json',
|
|
},
|
|
timeout=httpx.Timeout(60.0, connect=10.0),
|
|
)
|
|
return _client
|
|
|
|
|
|
async def close_client():
|
|
global _client
|
|
if _client:
|
|
await _client.aclose()
|
|
_client = None
|
|
|
|
|
|
async def _request(
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
json: Optional[dict] = None,
|
|
data: Optional[dict] = None,
|
|
files: Optional[dict] = None,
|
|
params: Optional[dict] = None,
|
|
max_retries: int = 3,
|
|
) -> dict[str, Any]:
|
|
"""Make an authenticated request to Freepik API with retry on 429."""
|
|
client = get_client()
|
|
for attempt in range(max_retries):
|
|
kwargs: dict[str, Any] = {}
|
|
if json is not None:
|
|
kwargs['json'] = json
|
|
if data is not None:
|
|
kwargs['data'] = data
|
|
if files is not None:
|
|
kwargs['files'] = files
|
|
if params is not None:
|
|
kwargs['params'] = params
|
|
|
|
response = await client.request(method, path, **kwargs)
|
|
|
|
if response.status_code == 429:
|
|
retry_after = int(response.headers.get('Retry-After', 2 ** attempt))
|
|
logger.warning(f'Rate limited, retrying in {retry_after}s (attempt {attempt + 1})')
|
|
await asyncio.sleep(retry_after)
|
|
continue
|
|
|
|
response.raise_for_status()
|
|
if response.headers.get('content-type', '').startswith('application/json'):
|
|
return response.json()
|
|
return {'raw': response.content}
|
|
|
|
raise httpx.HTTPStatusError(
|
|
'Rate limit exceeded after retries',
|
|
request=response.request,
|
|
response=response,
|
|
)
|
|
|
|
|
|
async def _request_raw(
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
json: Optional[dict] = None,
|
|
data: Optional[dict] = None,
|
|
files: Optional[dict] = None,
|
|
max_retries: int = 3,
|
|
) -> bytes:
|
|
"""Make a request and return raw bytes (for binary responses)."""
|
|
client = get_client()
|
|
for attempt in range(max_retries):
|
|
kwargs: dict[str, Any] = {}
|
|
if json is not None:
|
|
kwargs['json'] = json
|
|
if data is not None:
|
|
kwargs['data'] = data
|
|
if files is not None:
|
|
kwargs['files'] = files
|
|
|
|
response = await client.request(method, path, **kwargs)
|
|
|
|
if response.status_code == 429:
|
|
retry_after = int(response.headers.get('Retry-After', 2 ** attempt))
|
|
logger.warning(f'Rate limited, retrying in {retry_after}s (attempt {attempt + 1})')
|
|
await asyncio.sleep(retry_after)
|
|
continue
|
|
|
|
response.raise_for_status()
|
|
return response.content
|
|
|
|
raise httpx.HTTPStatusError(
|
|
'Rate limit exceeded after retries',
|
|
request=response.request,
|
|
response=response,
|
|
)
|
|
|
|
|
|
def _strip_none(d: dict) -> dict:
|
|
"""Remove None values from a dict for clean API payloads."""
|
|
return {k: v for k, v in d.items() if v is not None}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Image generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def generate_mystic(
|
|
prompt: str,
|
|
negative_prompt: Optional[str] = None,
|
|
resolution: Optional[str] = None,
|
|
styling: Optional[dict] = None,
|
|
seed: Optional[int] = None,
|
|
num_images: Optional[int] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'prompt': prompt,
|
|
'negative_prompt': negative_prompt,
|
|
'resolution': resolution,
|
|
'styling': styling,
|
|
'seed': seed,
|
|
'num_images': num_images,
|
|
})
|
|
return await _request('POST', '/v1/ai/mystic', json=payload)
|
|
|
|
|
|
async def generate_flux_dev(
|
|
prompt: str,
|
|
image: Optional[str] = None,
|
|
guidance_scale: Optional[float] = None,
|
|
num_images: Optional[int] = None,
|
|
seed: Optional[int] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'prompt': prompt,
|
|
'image': image,
|
|
'guidance_scale': guidance_scale,
|
|
'num_images': num_images,
|
|
'seed': seed,
|
|
})
|
|
return await _request('POST', '/v1/ai/text-to-image/flux-dev', json=payload)
|
|
|
|
|
|
async def generate_flux_pro(
|
|
prompt: str,
|
|
image: Optional[str] = None,
|
|
guidance_scale: Optional[float] = None,
|
|
seed: Optional[int] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'prompt': prompt,
|
|
'image': image,
|
|
'guidance_scale': guidance_scale,
|
|
'seed': seed,
|
|
})
|
|
return await _request('POST', '/v1/ai/text-to-image/flux-pro-1.1', json=payload)
|
|
|
|
|
|
async def generate_seedream(
|
|
prompt: str,
|
|
image: Optional[str] = None,
|
|
aspect_ratio: Optional[str] = None,
|
|
num_images: Optional[int] = None,
|
|
seed: Optional[int] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'prompt': prompt,
|
|
'image': image,
|
|
'aspect_ratio': aspect_ratio,
|
|
'num_images': num_images,
|
|
'seed': seed,
|
|
})
|
|
return await _request('POST', '/v1/ai/text-to-image/seedream', json=payload)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Video generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def generate_video_kling(
|
|
image: str,
|
|
prompt: Optional[str] = None,
|
|
duration: Optional[str] = None,
|
|
aspect_ratio: Optional[str] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'image': image,
|
|
'prompt': prompt,
|
|
'duration': duration,
|
|
'aspect_ratio': aspect_ratio,
|
|
})
|
|
return await _request('POST', '/v1/ai/image-to-video/kling', json=payload)
|
|
|
|
|
|
async def generate_video_minimax(
|
|
prompt: str,
|
|
first_frame_image: Optional[str] = None,
|
|
subject_reference: Optional[str] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'prompt': prompt,
|
|
'first_frame_image': first_frame_image,
|
|
'subject_reference': subject_reference,
|
|
})
|
|
return await _request('POST', '/v1/ai/image-to-video/minimax', json=payload)
|
|
|
|
|
|
async def generate_video_seedance(
|
|
prompt: str,
|
|
image: Optional[str] = None,
|
|
duration: Optional[str] = None,
|
|
resolution: Optional[str] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'prompt': prompt,
|
|
'image': image,
|
|
'duration': duration,
|
|
'resolution': resolution,
|
|
})
|
|
return await _request('POST', '/v1/ai/image-to-video/seedance', json=payload)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Image editing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def upscale_creative(
|
|
image: str,
|
|
prompt: Optional[str] = None,
|
|
scale: Optional[int] = None,
|
|
creativity: Optional[float] = None,
|
|
resemblance: Optional[float] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'image': image,
|
|
'prompt': prompt,
|
|
'scale': scale,
|
|
'creativity': creativity,
|
|
'resemblance': resemblance,
|
|
})
|
|
return await _request('POST', '/v1/ai/upscale/creative', json=payload)
|
|
|
|
|
|
async def upscale_precision(
|
|
image: str,
|
|
scale: Optional[int] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'image': image,
|
|
'scale': scale,
|
|
})
|
|
return await _request('POST', '/v1/ai/upscale/precision', json=payload)
|
|
|
|
|
|
async def relight_image(
|
|
image: str,
|
|
prompt: Optional[str] = None,
|
|
light_source: Optional[str] = None,
|
|
intensity: Optional[float] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'image': image,
|
|
'prompt': prompt,
|
|
'light_source': light_source,
|
|
'intensity': intensity,
|
|
})
|
|
return await _request('POST', '/v1/ai/relight', json=payload)
|
|
|
|
|
|
async def style_transfer(
|
|
image: str,
|
|
style_reference: str,
|
|
strength: Optional[float] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'image': image,
|
|
'style_reference': style_reference,
|
|
'strength': strength,
|
|
})
|
|
return await _request('POST', '/v1/ai/style-transfer', json=payload)
|
|
|
|
|
|
async def expand_image(
|
|
image: str,
|
|
prompt: Optional[str] = None,
|
|
direction: Optional[str] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'image': image,
|
|
'prompt': prompt,
|
|
'direction': direction,
|
|
})
|
|
return await _request('POST', '/v1/ai/expand', json=payload)
|
|
|
|
|
|
async def inpaint(
|
|
image: str,
|
|
mask: str,
|
|
prompt: str,
|
|
) -> dict:
|
|
payload = {
|
|
'image': image,
|
|
'mask': mask,
|
|
'prompt': prompt,
|
|
}
|
|
return await _request('POST', '/v1/ai/inpaint', json=payload)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utilities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def remove_background(image: str) -> bytes:
|
|
return await _request_raw('POST', '/v1/ai/remove-background', json={'image': image})
|
|
|
|
|
|
async def classify_image(image: str) -> dict:
|
|
return await _request('POST', '/v1/ai/classifier', json={'image': image})
|
|
|
|
|
|
async def isolate_audio(audio: str) -> dict:
|
|
return await _request('POST', '/v1/ai/audio-isolate', json={'audio': audio})
|
|
|
|
|
|
async def generate_icon(
|
|
prompt: str,
|
|
color: Optional[str] = None,
|
|
shape: Optional[str] = None,
|
|
style: Optional[str] = None,
|
|
) -> dict:
|
|
payload = _strip_none({
|
|
'prompt': prompt,
|
|
'color': color,
|
|
'shape': shape,
|
|
'style': style,
|
|
})
|
|
return await _request('POST', '/v1/ai/icon', json=payload)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def get_task_status(task_id: str) -> dict:
|
|
return await _request('GET', f'/v1/ai/tasks/{task_id}')
|
|
|
|
|
|
async def check_api_key() -> bool:
|
|
"""Verify the API key is valid by making a lightweight request."""
|
|
try:
|
|
client = get_client()
|
|
response = await client.get('/v1/ai/tasks', params={'limit': 1})
|
|
return response.status_code != 401
|
|
except Exception:
|
|
return False
|