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
|