Initial commit: Freepik REST API

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>
This commit is contained in:
2026-02-16 14:07:36 +01:00
commit 99c24adfe8
32 changed files with 1814 additions and 0 deletions

View File

@@ -0,0 +1,377 @@
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