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:
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
120
app/routers/image_editing.py
Normal file
120
app/routers/image_editing.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.schemas.common import TaskDetail, TaskResponse, TaskStatus
|
||||
from app.schemas.image_editing import (
|
||||
ExpandRequest,
|
||||
InpaintRequest,
|
||||
RelightRequest,
|
||||
StyleTransferRequest,
|
||||
UpscaleCreativeRequest,
|
||||
UpscalePrecisionRequest,
|
||||
)
|
||||
from app.services import freepik_client, task_tracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/api/v1/edit', tags=['image-editing'])
|
||||
|
||||
|
||||
async def _submit_and_respond(result: dict, sync: bool, metadata: dict) -> TaskResponse | TaskDetail:
|
||||
data = result.get('data', result)
|
||||
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
|
||||
if not freepik_task_id:
|
||||
raise HTTPException(status_code=502, detail='No task_id in Freepik response')
|
||||
|
||||
internal_id = task_tracker.submit(freepik_task_id, metadata)
|
||||
|
||||
if not sync:
|
||||
return TaskResponse(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.pending,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
elapsed = 0
|
||||
while elapsed < settings.task_poll_timeout_seconds:
|
||||
await asyncio.sleep(2)
|
||||
elapsed += 2
|
||||
task = task_tracker.get_task(internal_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=500, detail='Task disappeared')
|
||||
if task['status'] == TaskStatus.completed:
|
||||
return TaskDetail(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.completed,
|
||||
created_at=task['created_at'],
|
||||
updated_at=task['updated_at'],
|
||||
progress=1.0,
|
||||
result_url=f'/api/v1/tasks/{internal_id}/result',
|
||||
)
|
||||
if task['status'] == TaskStatus.failed:
|
||||
raise HTTPException(status_code=502, detail=task.get('error', 'Task failed'))
|
||||
|
||||
raise HTTPException(status_code=504, detail='Task did not complete in time')
|
||||
|
||||
|
||||
@router.post('/upscale/creative', response_model=TaskResponse)
|
||||
async def upscale_creative(request: UpscaleCreativeRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.upscale_creative(
|
||||
image=request.image,
|
||||
prompt=request.prompt,
|
||||
scale=request.scale,
|
||||
creativity=request.creativity,
|
||||
resemblance=request.resemblance,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'operation': 'upscale-creative'})
|
||||
|
||||
|
||||
@router.post('/upscale/precision', response_model=TaskResponse)
|
||||
async def upscale_precision(request: UpscalePrecisionRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.upscale_precision(
|
||||
image=request.image,
|
||||
scale=request.scale,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'operation': 'upscale-precision'})
|
||||
|
||||
|
||||
@router.post('/relight', response_model=TaskResponse)
|
||||
async def relight(request: RelightRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.relight_image(
|
||||
image=request.image,
|
||||
prompt=request.prompt,
|
||||
light_source=request.light_source,
|
||||
intensity=request.intensity,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'operation': 'relight'})
|
||||
|
||||
|
||||
@router.post('/style-transfer', response_model=TaskResponse)
|
||||
async def style_transfer(request: StyleTransferRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.style_transfer(
|
||||
image=request.image,
|
||||
style_reference=request.style_reference,
|
||||
strength=request.strength,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'operation': 'style-transfer'})
|
||||
|
||||
|
||||
@router.post('/expand', response_model=TaskResponse)
|
||||
async def expand(request: ExpandRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.expand_image(
|
||||
image=request.image,
|
||||
prompt=request.prompt,
|
||||
direction=request.direction,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'operation': 'expand'})
|
||||
|
||||
|
||||
@router.post('/inpaint', response_model=TaskResponse)
|
||||
async def inpaint(request: InpaintRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.inpaint(
|
||||
image=request.image,
|
||||
mask=request.mask,
|
||||
prompt=request.prompt,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'operation': 'inpaint'})
|
||||
110
app/routers/image_generation.py
Normal file
110
app/routers/image_generation.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.schemas.common import TaskDetail, TaskResponse, TaskStatus
|
||||
from app.schemas.image_generation import (
|
||||
FluxDevRequest,
|
||||
FluxProRequest,
|
||||
MysticRequest,
|
||||
SeedreamRequest,
|
||||
)
|
||||
from app.services import freepik_client, task_tracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/api/v1/generate/image', tags=['image-generation'])
|
||||
|
||||
|
||||
async def _submit_and_respond(
|
||||
result: dict,
|
||||
sync: bool,
|
||||
metadata: dict | None = None,
|
||||
) -> TaskResponse | TaskDetail:
|
||||
"""Extract task_id from Freepik response, track it, optionally wait."""
|
||||
data = result.get('data', result)
|
||||
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
|
||||
if not freepik_task_id:
|
||||
raise HTTPException(status_code=502, detail='No task_id in Freepik response')
|
||||
|
||||
internal_id = task_tracker.submit(freepik_task_id, metadata)
|
||||
|
||||
if not sync:
|
||||
return TaskResponse(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.pending,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Sync mode: wait for completion
|
||||
from app.config import settings
|
||||
elapsed = 0
|
||||
while elapsed < settings.task_poll_timeout_seconds:
|
||||
await asyncio.sleep(2)
|
||||
elapsed += 2
|
||||
task = task_tracker.get_task(internal_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=500, detail='Task disappeared')
|
||||
if task['status'] == TaskStatus.completed:
|
||||
return TaskDetail(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.completed,
|
||||
created_at=task['created_at'],
|
||||
updated_at=task['updated_at'],
|
||||
progress=1.0,
|
||||
result_url=f'/api/v1/tasks/{internal_id}/result',
|
||||
)
|
||||
if task['status'] == TaskStatus.failed:
|
||||
raise HTTPException(status_code=502, detail=task.get('error', 'Task failed'))
|
||||
|
||||
raise HTTPException(status_code=504, detail='Task did not complete in time')
|
||||
|
||||
|
||||
@router.post('/mystic', response_model=TaskResponse)
|
||||
async def generate_mystic(request: MysticRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.generate_mystic(
|
||||
prompt=request.prompt,
|
||||
negative_prompt=request.negative_prompt,
|
||||
resolution=request.resolution,
|
||||
styling=request.styling,
|
||||
seed=request.seed,
|
||||
num_images=request.num_images,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'model': 'mystic'})
|
||||
|
||||
|
||||
@router.post('/flux-dev', response_model=TaskResponse)
|
||||
async def generate_flux_dev(request: FluxDevRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.generate_flux_dev(
|
||||
prompt=request.prompt,
|
||||
image=request.image,
|
||||
guidance_scale=request.guidance_scale,
|
||||
num_images=request.num_images,
|
||||
seed=request.seed,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'model': 'flux-dev'})
|
||||
|
||||
|
||||
@router.post('/flux-pro', response_model=TaskResponse)
|
||||
async def generate_flux_pro(request: FluxProRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.generate_flux_pro(
|
||||
prompt=request.prompt,
|
||||
image=request.image,
|
||||
guidance_scale=request.guidance_scale,
|
||||
seed=request.seed,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'model': 'flux-pro'})
|
||||
|
||||
|
||||
@router.post('/seedream', response_model=TaskResponse)
|
||||
async def generate_seedream(request: SeedreamRequest, sync: bool = Query(False)):
|
||||
result = await freepik_client.generate_seedream(
|
||||
prompt=request.prompt,
|
||||
image=request.image,
|
||||
aspect_ratio=request.aspect_ratio,
|
||||
num_images=request.num_images,
|
||||
seed=request.seed,
|
||||
)
|
||||
return await _submit_and_respond(result, sync, {'model': 'seedream'})
|
||||
31
app/routers/system.py
Normal file
31
app/routers/system.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.schemas.system import HealthResponse, SystemInfoResponse
|
||||
from app.services import freepik_client, task_tracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/api/v1', tags=['system'])
|
||||
|
||||
|
||||
@router.get('/health', response_model=HealthResponse)
|
||||
async def health_check():
|
||||
return HealthResponse()
|
||||
|
||||
|
||||
@router.get('/system', response_model=SystemInfoResponse)
|
||||
async def system_info():
|
||||
api_key_valid = await freepik_client.check_api_key()
|
||||
mem = psutil.virtual_memory()
|
||||
|
||||
return SystemInfoResponse(
|
||||
api_key_valid=api_key_valid,
|
||||
active_tasks=task_tracker.active_count(),
|
||||
cpu_count=os.cpu_count() or 1,
|
||||
memory_total=mem.total,
|
||||
memory_available=mem.available,
|
||||
)
|
||||
75
app/routers/tasks.py
Normal file
75
app/routers/tasks.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.schemas.common import TaskDetail, TaskListResponse, TaskStatus
|
||||
from app.services import task_tracker
|
||||
from app.services.file_manager import get_result_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/api/v1/tasks', tags=['tasks'])
|
||||
|
||||
|
||||
@router.get('', response_model=TaskListResponse)
|
||||
async def list_tasks(
|
||||
status: Optional[TaskStatus] = Query(None),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
tasks, total = task_tracker.list_tasks(status=status, limit=limit, offset=offset)
|
||||
return TaskListResponse(
|
||||
tasks=[_to_detail(t) for t in tasks],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{task_id}', response_model=TaskDetail)
|
||||
async def get_task(task_id: str):
|
||||
task = task_tracker.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
return _to_detail(task)
|
||||
|
||||
|
||||
@router.get('/{task_id}/result')
|
||||
async def get_task_result(task_id: str):
|
||||
task = task_tracker.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
if task['status'] != TaskStatus.completed:
|
||||
raise HTTPException(status_code=409, detail=f'Task status is {task["status"].value}')
|
||||
|
||||
path = task.get('local_path') or get_result_path(task_id)
|
||||
if not path:
|
||||
# Fall back to redirect if we have a remote URL
|
||||
if task.get('result_url'):
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=task['result_url'])
|
||||
raise HTTPException(status_code=404, detail='Result file not found')
|
||||
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
@router.delete('/{task_id}')
|
||||
async def delete_task(task_id: str):
|
||||
if not task_tracker.delete_task(task_id):
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
return {'status': 'deleted', 'task_id': task_id}
|
||||
|
||||
|
||||
def _to_detail(task: dict) -> TaskDetail:
|
||||
result_url = None
|
||||
if task['status'] == TaskStatus.completed:
|
||||
result_url = f'/api/v1/tasks/{task["task_id"]}/result'
|
||||
return TaskDetail(
|
||||
task_id=task['task_id'],
|
||||
status=task['status'],
|
||||
created_at=task['created_at'],
|
||||
updated_at=task['updated_at'],
|
||||
progress=task.get('progress'),
|
||||
result_url=result_url,
|
||||
error=task.get('error'),
|
||||
)
|
||||
89
app/routers/utilities.py
Normal file
89
app/routers/utilities.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.schemas.common import TaskResponse, TaskStatus
|
||||
from app.schemas.utilities import ClassificationResponse, IconRequest
|
||||
from app.services import freepik_client, task_tracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/api/v1/util', tags=['utilities'])
|
||||
|
||||
|
||||
@router.post('/remove-background')
|
||||
async def remove_background(request: dict):
|
||||
"""Remove background from an image. Returns processed image as base64."""
|
||||
image = request.get('image')
|
||||
if not image:
|
||||
raise HTTPException(status_code=400, detail='image field is required')
|
||||
|
||||
result_bytes = await freepik_client.remove_background(image)
|
||||
return {
|
||||
'image': base64.b64encode(result_bytes).decode(),
|
||||
'content_type': 'image/png',
|
||||
}
|
||||
|
||||
|
||||
@router.post('/classify', response_model=ClassificationResponse)
|
||||
async def classify_image(request: dict):
|
||||
"""Classify whether an image is AI-generated."""
|
||||
image = request.get('image')
|
||||
if not image:
|
||||
raise HTTPException(status_code=400, detail='image field is required')
|
||||
|
||||
result = await freepik_client.classify_image(image)
|
||||
data = result.get('data', result)
|
||||
return ClassificationResponse(
|
||||
is_ai_generated=data.get('is_ai_generated', False),
|
||||
ai_probability=data.get('ai_probability', 0.0),
|
||||
human_probability=data.get('human_probability', 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/audio-isolate', response_model=TaskResponse)
|
||||
async def audio_isolate(request: dict):
|
||||
"""Isolate audio tracks from an audio file."""
|
||||
audio = request.get('audio')
|
||||
if not audio:
|
||||
raise HTTPException(status_code=400, detail='audio field is required')
|
||||
|
||||
result = await freepik_client.isolate_audio(audio)
|
||||
data = result.get('data', result)
|
||||
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
|
||||
if not freepik_task_id:
|
||||
raise HTTPException(status_code=502, detail='No task_id in Freepik response')
|
||||
|
||||
internal_id = task_tracker.submit(freepik_task_id, {'operation': 'audio-isolate'})
|
||||
return TaskResponse(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.pending,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
# Icon generation lives under /api/v1/generate/ but is simple enough to keep here
|
||||
icon_router = APIRouter(prefix='/api/v1/generate', tags=['utilities'])
|
||||
|
||||
|
||||
@icon_router.post('/icon', response_model=TaskResponse)
|
||||
async def generate_icon(request: IconRequest):
|
||||
result = await freepik_client.generate_icon(
|
||||
prompt=request.prompt,
|
||||
color=request.color,
|
||||
shape=request.shape,
|
||||
style=request.style,
|
||||
)
|
||||
data = result.get('data', result)
|
||||
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
|
||||
if not freepik_task_id:
|
||||
raise HTTPException(status_code=502, detail='No task_id in Freepik response')
|
||||
|
||||
internal_id = task_tracker.submit(freepik_task_id, {'operation': 'icon'})
|
||||
return TaskResponse(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.pending,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
70
app/routers/video_generation.py
Normal file
70
app/routers/video_generation.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.schemas.common import TaskResponse, TaskStatus
|
||||
from app.schemas.video_generation import KlingRequest, MinimaxRequest, SeedanceRequest
|
||||
from app.services import freepik_client, task_tracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/api/v1/generate/video', tags=['video-generation'])
|
||||
|
||||
|
||||
def _extract_task_id(result: dict) -> str:
|
||||
data = result.get('data', result)
|
||||
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
|
||||
if not freepik_task_id:
|
||||
raise HTTPException(status_code=502, detail='No task_id in Freepik response')
|
||||
return freepik_task_id
|
||||
|
||||
|
||||
@router.post('/kling', response_model=TaskResponse)
|
||||
async def generate_video_kling(request: KlingRequest):
|
||||
result = await freepik_client.generate_video_kling(
|
||||
image=request.image,
|
||||
prompt=request.prompt,
|
||||
duration=request.duration,
|
||||
aspect_ratio=request.aspect_ratio,
|
||||
)
|
||||
freepik_id = _extract_task_id(result)
|
||||
internal_id = task_tracker.submit(freepik_id, {'model': 'kling'})
|
||||
return TaskResponse(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.pending,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/minimax', response_model=TaskResponse)
|
||||
async def generate_video_minimax(request: MinimaxRequest):
|
||||
result = await freepik_client.generate_video_minimax(
|
||||
prompt=request.prompt,
|
||||
first_frame_image=request.first_frame_image,
|
||||
subject_reference=request.subject_reference,
|
||||
)
|
||||
freepik_id = _extract_task_id(result)
|
||||
internal_id = task_tracker.submit(freepik_id, {'model': 'minimax'})
|
||||
return TaskResponse(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.pending,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/seedance', response_model=TaskResponse)
|
||||
async def generate_video_seedance(request: SeedanceRequest):
|
||||
result = await freepik_client.generate_video_seedance(
|
||||
prompt=request.prompt,
|
||||
image=request.image,
|
||||
duration=request.duration,
|
||||
resolution=request.resolution,
|
||||
)
|
||||
freepik_id = _extract_task_id(result)
|
||||
internal_id = task_tracker.submit(freepik_id, {'model': 'seedance'})
|
||||
return TaskResponse(
|
||||
task_id=internal_id,
|
||||
status=TaskStatus.pending,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
Reference in New Issue
Block a user