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

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,67 @@
import logging
import os
import time
from pathlib import Path
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
def ensure_directories():
os.makedirs(settings.output_dir, exist_ok=True)
os.makedirs(settings.temp_dir, exist_ok=True)
async def download_result(task_id: str, url: str) -> str:
"""Download a result file from a URL and store it locally."""
task_dir = os.path.join(settings.output_dir, task_id)
os.makedirs(task_dir, exist_ok=True)
# Derive filename from URL
filename = url.rsplit('/', 1)[-1].split('?')[0] or 'result'
if '.' not in filename:
filename += '.png'
output_path = os.path.join(task_dir, filename)
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.get(url)
response.raise_for_status()
with open(output_path, 'wb') as f:
f.write(response.content)
logger.info(f'Downloaded result for {task_id}: {output_path}')
return output_path
def get_result_path(task_id: str) -> str | None:
"""Get the path to a task's result file."""
task_dir = os.path.join(settings.output_dir, task_id)
if not os.path.isdir(task_dir):
return None
files = os.listdir(task_dir)
if not files:
return None
return os.path.join(task_dir, files[0])
def cleanup_old_outputs():
"""Remove output directories older than auto_cleanup_hours."""
cutoff = time.time() - (settings.auto_cleanup_hours * 3600)
output_dir = Path(settings.output_dir)
if not output_dir.exists():
return
removed = 0
for task_dir in output_dir.iterdir():
if task_dir.is_dir() and task_dir.stat().st_mtime < cutoff:
for f in task_dir.iterdir():
f.unlink()
task_dir.rmdir()
removed += 1
if removed:
logger.info(f'Cleaned up {removed} old output directories')

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

View File

@@ -0,0 +1,179 @@
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
from app.config import settings
from app.schemas.common import TaskStatus
from app.services import freepik_client
from app.services.file_manager import download_result
logger = logging.getLogger(__name__)
_tasks: dict[str, dict] = {}
_poll_tasks: dict[str, asyncio.Task] = {}
def submit(freepik_task_id: str, metadata: Optional[dict] = None) -> str:
"""Register a Freepik task and start background polling."""
internal_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
_tasks[internal_id] = {
'task_id': internal_id,
'freepik_task_id': freepik_task_id,
'status': TaskStatus.pending,
'created_at': now,
'updated_at': now,
'progress': None,
'result_url': None,
'local_path': None,
'error': None,
'metadata': metadata or {},
}
_poll_tasks[internal_id] = asyncio.create_task(_poll_loop(internal_id))
return internal_id
def get_task(task_id: str) -> Optional[dict]:
return _tasks.get(task_id)
def list_tasks(
status: Optional[TaskStatus] = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict], int]:
tasks = list(_tasks.values())
if status:
tasks = [t for t in tasks if t['status'] == status]
tasks.sort(key=lambda t: t['created_at'], reverse=True)
total = len(tasks)
return tasks[offset:offset + limit], total
def delete_task(task_id: str) -> bool:
if task_id not in _tasks:
return False
# Cancel polling if active
poll = _poll_tasks.pop(task_id, None)
if poll and not poll.done():
poll.cancel()
_tasks.pop(task_id, None)
return True
def active_count() -> int:
return sum(
1 for t in _tasks.values()
if t['status'] in (TaskStatus.pending, TaskStatus.processing)
)
async def _poll_loop(internal_id: str):
"""Poll Freepik API until the task completes or times out."""
task = _tasks.get(internal_id)
if not task:
return
freepik_id = task['freepik_task_id']
elapsed = 0
try:
while elapsed < settings.task_poll_timeout_seconds:
await asyncio.sleep(settings.task_poll_interval_seconds)
elapsed += settings.task_poll_interval_seconds
try:
result = await freepik_client.get_task_status(freepik_id)
except Exception as exc:
logger.warning(f'Poll error for {internal_id}: {exc}')
continue
data = result.get('data', result)
fp_status = data.get('status', '')
task['updated_at'] = datetime.now(timezone.utc)
if fp_status in ('IN_PROGRESS', 'PROCESSING', 'processing'):
task['status'] = TaskStatus.processing
task['progress'] = data.get('progress')
continue
if fp_status in ('COMPLETED', 'completed', 'done'):
task['status'] = TaskStatus.completed
task['progress'] = 1.0
# Extract result URL from various response shapes
result_url = (
data.get('result_url')
or data.get('output', {}).get('url')
or _extract_first_url(data)
)
task['result_url'] = result_url
if result_url:
try:
task['local_path'] = await download_result(
internal_id, result_url
)
except Exception as exc:
logger.error(f'Download failed for {internal_id}: {exc}')
logger.info(f'Task {internal_id} completed')
return
if fp_status in ('FAILED', 'failed', 'error'):
task['status'] = TaskStatus.failed
task['error'] = data.get('error', data.get('message', 'Unknown error'))
logger.warning(f'Task {internal_id} failed: {task["error"]}')
return
# Timeout
task['status'] = TaskStatus.failed
task['error'] = f'Polling timed out after {settings.task_poll_timeout_seconds}s'
logger.warning(f'Task {internal_id} timed out')
except asyncio.CancelledError:
logger.info(f'Polling cancelled for {internal_id}')
except Exception as exc:
task['status'] = TaskStatus.failed
task['error'] = str(exc)
logger.error(f'Unexpected error polling {internal_id}: {exc}')
finally:
_poll_tasks.pop(internal_id, None)
def _extract_first_url(data: dict) -> Optional[str]:
"""Try to extract the first URL from common Freepik response shapes."""
# Some endpoints return {"data": {"images": [{"url": "..."}]}}
for key in ('images', 'videos', 'results', 'outputs'):
items = data.get(key, [])
if isinstance(items, list) and items:
first = items[0]
if isinstance(first, dict) and 'url' in first:
return first['url']
if isinstance(first, str) and first.startswith('http'):
return first
# Direct URL field
if 'url' in data and isinstance(data['url'], str):
return data['url']
return None
def handle_webhook_completion(freepik_task_id: str, result_data: dict):
"""Called when a webhook notification arrives for a completed task."""
for task in _tasks.values():
if task['freepik_task_id'] == freepik_task_id:
task['status'] = TaskStatus.completed
task['progress'] = 1.0
task['updated_at'] = datetime.now(timezone.utc)
result_url = (
result_data.get('result_url')
or result_data.get('output', {}).get('url')
or _extract_first_url(result_data)
)
task['result_url'] = result_url
# Cancel polling since webhook already notified us
poll = _poll_tasks.pop(task['task_id'], None)
if poll and not poll.done():
poll.cancel()
logger.info(f'Task {task["task_id"]} completed via webhook')
break

39
app/services/webhook.py Normal file
View File

@@ -0,0 +1,39 @@
import hashlib
import hmac
import logging
from fastapi import APIRouter, Header, HTTPException, Request
from app.config import settings
from app.services.task_tracker import handle_webhook_completion
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/api/v1', tags=['webhooks'])
@router.post('/webhooks/task-complete')
async def webhook_task_complete(
request: Request,
x_webhook_signature: str | None = Header(None),
):
body = await request.body()
if settings.webhook_secret:
if not x_webhook_signature:
raise HTTPException(status_code=401, detail='Missing webhook signature')
expected = hmac.new(
settings.webhook_secret.encode(),
body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, x_webhook_signature):
raise HTTPException(status_code=401, detail='Invalid webhook signature')
data = await request.json()
task_id = data.get('task_id') or data.get('id')
if not task_id:
raise HTTPException(status_code=400, detail='Missing task_id in webhook payload')
handle_webhook_completion(str(task_id), data)
return {'status': 'ok'}