fix: align Freepik API paths with OpenAPI spec
Some checks failed
Build and Push Docker Image / build (push) Failing after 9s

The original implementation used guessed endpoint paths that don't match
the actual Freepik API. Key fixes based on their OpenAPI spec:

- Task polling is per-endpoint (e.g. GET /v1/ai/text-to-image/flux-dev/{task-id})
  not a generic /v1/ai/tasks/{id}. freepik_client now returns TaskResult
  with status_path, and task_tracker polls using that path.
- Fixed endpoint paths: flux-pro -> flux-pro-v1-1, upscale -> image-upscaler,
  relight -> image-relight, style-transfer -> image-style-transfer,
  expand -> image-expand/flux-pro, inpaint -> ideogram-image-edit,
  remove-background -> beta/remove-background, classifier -> classifier/image,
  audio-isolate -> audio-isolation, icon -> text-to-icon
- Fixed video paths: kling -> kling-o1-pro with kling-o1 status path,
  minimax -> minimax-hailuo-02-1080p, seedance -> seedance-pro-1080p
- Fixed request schemas to match actual API params (e.g. scale_factor
  not scale, reference_image not style_reference, image_url for bg removal)
- Fixed response parsing: status is uppercase (COMPLETED not completed),
  results in data.generated[] array, classifier returns [{class_name, probability}]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 16:26:42 +01:00
parent 99c24adfe8
commit 1a5c686dfc
10 changed files with 333 additions and 269 deletions

View File

@@ -14,19 +14,19 @@ from app.schemas.image_editing import (
UpscalePrecisionRequest, UpscalePrecisionRequest,
) )
from app.services import freepik_client, task_tracker from app.services import freepik_client, task_tracker
from app.services.freepik_client import TaskResult, _extract_task_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix='/api/v1/edit', tags=['image-editing']) router = APIRouter(prefix='/api/v1/edit', tags=['image-editing'])
async def _submit_and_respond(result: dict, sync: bool, metadata: dict) -> TaskResponse | TaskDetail: async def _submit_and_respond(result: TaskResult, sync: bool, metadata: dict) -> TaskResponse | TaskDetail:
data = result.get('data', result) freepik_task_id = _extract_task_id(result.data)
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
if not freepik_task_id: if not freepik_task_id:
raise HTTPException(status_code=502, detail='No task_id in Freepik response') raise HTTPException(status_code=502, detail='No task_id in Freepik response')
internal_id = task_tracker.submit(freepik_task_id, metadata) internal_id = task_tracker.submit(freepik_task_id, result.status_path, metadata)
if not sync: if not sync:
return TaskResponse( return TaskResponse(
@@ -63,9 +63,10 @@ async def upscale_creative(request: UpscaleCreativeRequest, sync: bool = Query(F
result = await freepik_client.upscale_creative( result = await freepik_client.upscale_creative(
image=request.image, image=request.image,
prompt=request.prompt, prompt=request.prompt,
scale=request.scale, scale_factor=request.scale_factor,
creativity=request.creativity, creativity=request.creativity,
resemblance=request.resemblance, resemblance=request.resemblance,
optimized_for=request.optimized_for,
) )
return await _submit_and_respond(result, sync, {'operation': 'upscale-creative'}) return await _submit_and_respond(result, sync, {'operation': 'upscale-creative'})
@@ -74,7 +75,7 @@ async def upscale_creative(request: UpscaleCreativeRequest, sync: bool = Query(F
async def upscale_precision(request: UpscalePrecisionRequest, sync: bool = Query(False)): async def upscale_precision(request: UpscalePrecisionRequest, sync: bool = Query(False)):
result = await freepik_client.upscale_precision( result = await freepik_client.upscale_precision(
image=request.image, image=request.image,
scale=request.scale, scale_factor=request.scale_factor,
) )
return await _submit_and_respond(result, sync, {'operation': 'upscale-precision'}) return await _submit_and_respond(result, sync, {'operation': 'upscale-precision'})
@@ -84,8 +85,8 @@ async def relight(request: RelightRequest, sync: bool = Query(False)):
result = await freepik_client.relight_image( result = await freepik_client.relight_image(
image=request.image, image=request.image,
prompt=request.prompt, prompt=request.prompt,
light_source=request.light_source, transfer_light_from_reference_image=request.transfer_light_from_reference_image,
intensity=request.intensity, light_transfer_strength=request.light_transfer_strength,
) )
return await _submit_and_respond(result, sync, {'operation': 'relight'}) return await _submit_and_respond(result, sync, {'operation': 'relight'})
@@ -94,8 +95,10 @@ async def relight(request: RelightRequest, sync: bool = Query(False)):
async def style_transfer(request: StyleTransferRequest, sync: bool = Query(False)): async def style_transfer(request: StyleTransferRequest, sync: bool = Query(False)):
result = await freepik_client.style_transfer( result = await freepik_client.style_transfer(
image=request.image, image=request.image,
style_reference=request.style_reference, reference_image=request.reference_image,
strength=request.strength, prompt=request.prompt,
style_strength=request.style_strength,
structure_strength=request.structure_strength,
) )
return await _submit_and_respond(result, sync, {'operation': 'style-transfer'}) return await _submit_and_respond(result, sync, {'operation': 'style-transfer'})
@@ -105,7 +108,6 @@ async def expand(request: ExpandRequest, sync: bool = Query(False)):
result = await freepik_client.expand_image( result = await freepik_client.expand_image(
image=request.image, image=request.image,
prompt=request.prompt, prompt=request.prompt,
direction=request.direction,
) )
return await _submit_and_respond(result, sync, {'operation': 'expand'}) return await _submit_and_respond(result, sync, {'operation': 'expand'})

View File

@@ -11,7 +11,8 @@ from app.schemas.image_generation import (
MysticRequest, MysticRequest,
SeedreamRequest, SeedreamRequest,
) )
from app.services import freepik_client, task_tracker from app.services import task_tracker
from app.services.freepik_client import TaskResult, _extract_task_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,17 +20,16 @@ router = APIRouter(prefix='/api/v1/generate/image', tags=['image-generation'])
async def _submit_and_respond( async def _submit_and_respond(
result: dict, result: TaskResult,
sync: bool, sync: bool,
metadata: dict | None = None, metadata: dict | None = None,
) -> TaskResponse | TaskDetail: ) -> TaskResponse | TaskDetail:
"""Extract task_id from Freepik response, track it, optionally wait.""" """Extract task_id from Freepik response, track it, optionally wait."""
data = result.get('data', result) freepik_task_id = _extract_task_id(result.data)
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
if not freepik_task_id: if not freepik_task_id:
raise HTTPException(status_code=502, detail='No task_id in Freepik response') raise HTTPException(status_code=502, detail='No task_id in Freepik response')
internal_id = task_tracker.submit(freepik_task_id, metadata) internal_id = task_tracker.submit(freepik_task_id, result.status_path, metadata)
if not sync: if not sync:
return TaskResponse( return TaskResponse(
@@ -64,24 +64,27 @@ async def _submit_and_respond(
@router.post('/mystic', response_model=TaskResponse) @router.post('/mystic', response_model=TaskResponse)
async def generate_mystic(request: MysticRequest, sync: bool = Query(False)): async def generate_mystic(request: MysticRequest, sync: bool = Query(False)):
from app.services import freepik_client
result = await freepik_client.generate_mystic( result = await freepik_client.generate_mystic(
prompt=request.prompt, prompt=request.prompt,
negative_prompt=request.negative_prompt,
resolution=request.resolution, resolution=request.resolution,
styling=request.styling, aspect_ratio=request.aspect_ratio,
model=request.model,
seed=request.seed, seed=request.seed,
num_images=request.num_images, styling=request.styling,
structure_reference=request.structure_reference,
style_reference=request.style_reference,
) )
return await _submit_and_respond(result, sync, {'model': 'mystic'}) return await _submit_and_respond(result, sync, {'model': 'mystic'})
@router.post('/flux-dev', response_model=TaskResponse) @router.post('/flux-dev', response_model=TaskResponse)
async def generate_flux_dev(request: FluxDevRequest, sync: bool = Query(False)): async def generate_flux_dev(request: FluxDevRequest, sync: bool = Query(False)):
from app.services import freepik_client
result = await freepik_client.generate_flux_dev( result = await freepik_client.generate_flux_dev(
prompt=request.prompt, prompt=request.prompt,
image=request.image, aspect_ratio=request.aspect_ratio,
guidance_scale=request.guidance_scale, styling=request.styling,
num_images=request.num_images,
seed=request.seed, seed=request.seed,
) )
return await _submit_and_respond(result, sync, {'model': 'flux-dev'}) return await _submit_and_respond(result, sync, {'model': 'flux-dev'})
@@ -89,10 +92,11 @@ async def generate_flux_dev(request: FluxDevRequest, sync: bool = Query(False)):
@router.post('/flux-pro', response_model=TaskResponse) @router.post('/flux-pro', response_model=TaskResponse)
async def generate_flux_pro(request: FluxProRequest, sync: bool = Query(False)): async def generate_flux_pro(request: FluxProRequest, sync: bool = Query(False)):
from app.services import freepik_client
result = await freepik_client.generate_flux_pro( result = await freepik_client.generate_flux_pro(
prompt=request.prompt, prompt=request.prompt,
image=request.image, aspect_ratio=request.aspect_ratio,
guidance_scale=request.guidance_scale, styling=request.styling,
seed=request.seed, seed=request.seed,
) )
return await _submit_and_respond(result, sync, {'model': 'flux-pro'}) return await _submit_and_respond(result, sync, {'model': 'flux-pro'})
@@ -100,11 +104,10 @@ async def generate_flux_pro(request: FluxProRequest, sync: bool = Query(False)):
@router.post('/seedream', response_model=TaskResponse) @router.post('/seedream', response_model=TaskResponse)
async def generate_seedream(request: SeedreamRequest, sync: bool = Query(False)): async def generate_seedream(request: SeedreamRequest, sync: bool = Query(False)):
from app.services import freepik_client
result = await freepik_client.generate_seedream( result = await freepik_client.generate_seedream(
prompt=request.prompt, prompt=request.prompt,
image=request.image,
aspect_ratio=request.aspect_ratio, aspect_ratio=request.aspect_ratio,
num_images=request.num_images,
seed=request.seed, seed=request.seed,
) )
return await _submit_and_respond(result, sync, {'model': 'seedream'}) return await _submit_and_respond(result, sync, {'model': 'seedream'})

View File

@@ -1,30 +1,28 @@
import base64
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.schemas.common import TaskResponse, TaskStatus from app.schemas.common import TaskResponse, TaskStatus
from app.schemas.utilities import ClassificationResponse, IconRequest from app.schemas.utilities import (
ClassificationResponse,
IconRequest,
RemoveBackgroundRequest,
RemoveBackgroundResponse,
)
from app.services import freepik_client, task_tracker from app.services import freepik_client, task_tracker
from app.services.freepik_client import _extract_task_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix='/api/v1/util', tags=['utilities']) router = APIRouter(prefix='/api/v1/util', tags=['utilities'])
@router.post('/remove-background') @router.post('/remove-background', response_model=RemoveBackgroundResponse)
async def remove_background(request: dict): async def remove_background(request: RemoveBackgroundRequest):
"""Remove background from an image. Returns processed image as base64.""" """Remove background from an image. Takes a URL, returns result URLs."""
image = request.get('image') result = await freepik_client.remove_background(request.image_url)
if not image: return RemoveBackgroundResponse(**result)
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) @router.post('/classify', response_model=ClassificationResponse)
@@ -35,12 +33,7 @@ async def classify_image(request: dict):
raise HTTPException(status_code=400, detail='image field is required') raise HTTPException(status_code=400, detail='image field is required')
result = await freepik_client.classify_image(image) result = await freepik_client.classify_image(image)
data = result.get('data', result) return 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) @router.post('/audio-isolate', response_model=TaskResponse)
@@ -51,12 +44,11 @@ async def audio_isolate(request: dict):
raise HTTPException(status_code=400, detail='audio field is required') raise HTTPException(status_code=400, detail='audio field is required')
result = await freepik_client.isolate_audio(audio) result = await freepik_client.isolate_audio(audio)
data = result.get('data', result) freepik_task_id = _extract_task_id(result.data)
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
if not freepik_task_id: if not freepik_task_id:
raise HTTPException(status_code=502, detail='No task_id in Freepik response') raise HTTPException(status_code=502, detail='No task_id in Freepik response')
internal_id = task_tracker.submit(freepik_task_id, {'operation': 'audio-isolate'}) internal_id = task_tracker.submit(freepik_task_id, result.status_path, {'operation': 'audio-isolate'})
return TaskResponse( return TaskResponse(
task_id=internal_id, task_id=internal_id,
status=TaskStatus.pending, status=TaskStatus.pending,
@@ -76,12 +68,11 @@ async def generate_icon(request: IconRequest):
shape=request.shape, shape=request.shape,
style=request.style, style=request.style,
) )
data = result.get('data', result) freepik_task_id = _extract_task_id(result.data)
freepik_task_id = str(data.get('task_id') or data.get('id', ''))
if not freepik_task_id: if not freepik_task_id:
raise HTTPException(status_code=502, detail='No task_id in Freepik response') raise HTTPException(status_code=502, detail='No task_id in Freepik response')
internal_id = task_tracker.submit(freepik_task_id, {'operation': 'icon'}) internal_id = task_tracker.submit(freepik_task_id, result.status_path, {'operation': 'icon'})
return TaskResponse( return TaskResponse(
task_id=internal_id, task_id=internal_id,
status=TaskStatus.pending, status=TaskStatus.pending,

View File

@@ -6,30 +6,26 @@ from fastapi import APIRouter, HTTPException
from app.schemas.common import TaskResponse, TaskStatus from app.schemas.common import TaskResponse, TaskStatus
from app.schemas.video_generation import KlingRequest, MinimaxRequest, SeedanceRequest from app.schemas.video_generation import KlingRequest, MinimaxRequest, SeedanceRequest
from app.services import freepik_client, task_tracker from app.services import freepik_client, task_tracker
from app.services.freepik_client import _extract_task_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix='/api/v1/generate/video', tags=['video-generation']) 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) @router.post('/kling', response_model=TaskResponse)
async def generate_video_kling(request: KlingRequest): async def generate_video_kling(request: KlingRequest):
result = await freepik_client.generate_video_kling( result = await freepik_client.generate_video_kling(
image=request.image, first_frame=request.first_frame,
last_frame=request.last_frame,
prompt=request.prompt, prompt=request.prompt,
duration=request.duration, duration=request.duration,
aspect_ratio=request.aspect_ratio, aspect_ratio=request.aspect_ratio,
) )
freepik_id = _extract_task_id(result) freepik_id = _extract_task_id(result.data)
internal_id = task_tracker.submit(freepik_id, {'model': 'kling'}) if not freepik_id:
raise HTTPException(status_code=502, detail='No task_id in Freepik response')
internal_id = task_tracker.submit(freepik_id, result.status_path, {'model': 'kling'})
return TaskResponse( return TaskResponse(
task_id=internal_id, task_id=internal_id,
status=TaskStatus.pending, status=TaskStatus.pending,
@@ -42,10 +38,12 @@ async def generate_video_minimax(request: MinimaxRequest):
result = await freepik_client.generate_video_minimax( result = await freepik_client.generate_video_minimax(
prompt=request.prompt, prompt=request.prompt,
first_frame_image=request.first_frame_image, first_frame_image=request.first_frame_image,
subject_reference=request.subject_reference, last_frame_image=request.last_frame_image,
) )
freepik_id = _extract_task_id(result) freepik_id = _extract_task_id(result.data)
internal_id = task_tracker.submit(freepik_id, {'model': 'minimax'}) if not freepik_id:
raise HTTPException(status_code=502, detail='No task_id in Freepik response')
internal_id = task_tracker.submit(freepik_id, result.status_path, {'model': 'minimax'})
return TaskResponse( return TaskResponse(
task_id=internal_id, task_id=internal_id,
status=TaskStatus.pending, status=TaskStatus.pending,
@@ -59,10 +57,11 @@ async def generate_video_seedance(request: SeedanceRequest):
prompt=request.prompt, prompt=request.prompt,
image=request.image, image=request.image,
duration=request.duration, duration=request.duration,
resolution=request.resolution,
) )
freepik_id = _extract_task_id(result) freepik_id = _extract_task_id(result.data)
internal_id = task_tracker.submit(freepik_id, {'model': 'seedance'}) if not freepik_id:
raise HTTPException(status_code=502, detail='No task_id in Freepik response')
internal_id = task_tracker.submit(freepik_id, result.status_path, {'model': 'seedance'})
return TaskResponse( return TaskResponse(
task_id=internal_id, task_id=internal_id,
status=TaskStatus.pending, status=TaskStatus.pending,

View File

@@ -6,36 +6,43 @@ from pydantic import BaseModel, Field
class UpscaleCreativeRequest(BaseModel): class UpscaleCreativeRequest(BaseModel):
image: str = Field(..., description='Base64-encoded image') image: str = Field(..., description='Base64-encoded image')
prompt: Optional[str] = None prompt: Optional[str] = None
scale: Optional[int] = Field(None, ge=2, le=4) scale_factor: Optional[str] = Field(None, description='2x, 4x, 8x, or 16x')
creativity: Optional[float] = Field(None, ge=0.0, le=1.0) creativity: Optional[int] = Field(None, ge=-10, le=10)
resemblance: Optional[float] = Field(None, ge=0.0, le=1.0) resemblance: Optional[int] = Field(None, ge=-10, le=10)
optimized_for: Optional[str] = Field(
None,
description='standard, soft_portraits, hard_portraits, art_n_illustration, etc.',
)
class UpscalePrecisionRequest(BaseModel): class UpscalePrecisionRequest(BaseModel):
image: str = Field(..., description='Base64-encoded image') image: str = Field(..., description='Base64-encoded image')
scale: Optional[int] = Field(None, ge=2, le=4) scale_factor: Optional[str] = Field(None, description='2x or 4x')
class RelightRequest(BaseModel): class RelightRequest(BaseModel):
image: str = Field(..., description='Base64-encoded image') image: str = Field(..., description='Base64 or URL of image')
prompt: Optional[str] = None prompt: Optional[str] = None
light_source: Optional[str] = None transfer_light_from_reference_image: Optional[str] = Field(
intensity: Optional[float] = Field(None, ge=0.0, le=1.0) None, description='Base64 or URL of reference image for light transfer',
)
light_transfer_strength: Optional[int] = Field(None, ge=0, le=100)
class StyleTransferRequest(BaseModel): class StyleTransferRequest(BaseModel):
image: str = Field(..., description='Base64-encoded image') image: str = Field(..., description='Base64 or URL of image')
style_reference: str = Field(..., description='Base64-encoded style reference image') reference_image: str = Field(..., description='Base64 or URL of style reference image')
strength: Optional[float] = Field(None, ge=0.0, le=1.0) prompt: Optional[str] = None
style_strength: Optional[int] = Field(None, ge=0, le=100)
structure_strength: Optional[int] = Field(None, ge=0, le=100)
class ExpandRequest(BaseModel): class ExpandRequest(BaseModel):
image: str = Field(..., description='Base64-encoded image') image: str = Field(..., description='Base64 or URL of image')
prompt: Optional[str] = None prompt: Optional[str] = None
direction: Optional[str] = Field(None, description='Expansion direction')
class InpaintRequest(BaseModel): class InpaintRequest(BaseModel):
image: str = Field(..., description='Base64-encoded image') image: str = Field(..., description='Base64 or URL of image')
mask: str = Field(..., description='Base64-encoded mask image') mask: str = Field(..., description='Base64 or URL of mask image')
prompt: str = Field(..., min_length=1) prompt: str = Field(..., min_length=1)

View File

@@ -5,31 +5,30 @@ from pydantic import BaseModel, Field
class MysticRequest(BaseModel): class MysticRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4000) prompt: str = Field(..., min_length=1, max_length=4000)
negative_prompt: Optional[str] = None resolution: Optional[str] = Field(None, description='1k, 2k, or 4k')
resolution: Optional[str] = None aspect_ratio: Optional[str] = Field(None, description='e.g. square_1_1, widescreen_16_9')
model: Optional[str] = Field(None, description='realism, fluid, zen, flexible, super_real, editorial_portraits')
seed: Optional[int] = Field(None, ge=1, le=4294967295)
styling: Optional[dict] = None styling: Optional[dict] = None
seed: Optional[int] = None structure_reference: Optional[str] = Field(None, description='Base64 image for structure reference')
num_images: Optional[int] = Field(None, ge=1, le=4) style_reference: Optional[str] = Field(None, description='Base64 image for style reference')
class FluxDevRequest(BaseModel): class FluxDevRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4000) prompt: str = Field(..., min_length=1, max_length=4000)
image: Optional[str] = Field(None, description='Base64-encoded image for img2img') aspect_ratio: Optional[str] = Field(None, description='e.g. square_1_1, widescreen_16_9')
guidance_scale: Optional[float] = Field(None, ge=1.0, le=20.0) styling: Optional[dict] = None
num_images: Optional[int] = Field(None, ge=1, le=4) seed: Optional[int] = Field(None, ge=1, le=4294967295)
seed: Optional[int] = None
class FluxProRequest(BaseModel): class FluxProRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4000) prompt: str = Field(..., min_length=1, max_length=4000)
image: Optional[str] = Field(None, description='Base64-encoded image for img2img') aspect_ratio: Optional[str] = Field(None, description='e.g. square_1_1, widescreen_16_9')
guidance_scale: Optional[float] = Field(None, ge=1.0, le=20.0) styling: Optional[dict] = None
seed: Optional[int] = None seed: Optional[int] = Field(None, ge=1, le=4294967295)
class SeedreamRequest(BaseModel): class SeedreamRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4000) prompt: str = Field(..., min_length=1, max_length=4000)
image: Optional[str] = Field(None, description='Base64-encoded image for img2img') aspect_ratio: Optional[str] = Field(None, description='e.g. square_1_1, widescreen_16_9')
aspect_ratio: Optional[str] = None seed: Optional[int] = Field(None, ge=1, le=4294967295)
num_images: Optional[int] = Field(None, ge=1, le=4)
seed: Optional[int] = None

View File

@@ -3,10 +3,24 @@ from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ClassificationResult(BaseModel):
class_name: str
probability: float
class ClassificationResponse(BaseModel): class ClassificationResponse(BaseModel):
is_ai_generated: bool data: list[ClassificationResult]
ai_probability: float
human_probability: float
class RemoveBackgroundRequest(BaseModel):
image_url: str = Field(..., description='URL of image to remove background from')
class RemoveBackgroundResponse(BaseModel):
original: Optional[str] = None
high_resolution: Optional[str] = None
preview: Optional[str] = None
url: Optional[str] = None
class IconRequest(BaseModel): class IconRequest(BaseModel):

View File

@@ -4,20 +4,20 @@ from pydantic import BaseModel, Field
class KlingRequest(BaseModel): class KlingRequest(BaseModel):
image: str = Field(..., description='Base64-encoded image') first_frame: Optional[str] = Field(None, description='Base64 or URL of first frame image')
prompt: Optional[str] = None last_frame: Optional[str] = Field(None, description='Base64 or URL of last frame image')
duration: Optional[str] = Field(None, description='5 or 10 seconds') prompt: Optional[str] = Field(None, max_length=2500)
aspect_ratio: Optional[str] = None duration: Optional[int] = Field(None, description='5 or 10 seconds')
aspect_ratio: Optional[str] = Field(None, description='16:9, 9:16, or 1:1')
class MinimaxRequest(BaseModel): class MinimaxRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4000) prompt: str = Field(..., min_length=1, max_length=4000)
first_frame_image: Optional[str] = Field(None, description='Base64-encoded image') first_frame_image: Optional[str] = Field(None, description='Base64 or URL of first frame')
subject_reference: Optional[str] = Field(None, description='Base64-encoded reference image') last_frame_image: Optional[str] = Field(None, description='Base64 or URL of last frame')
class SeedanceRequest(BaseModel): class SeedanceRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=4000) prompt: str = Field(..., min_length=1, max_length=2000)
image: Optional[str] = Field(None, description='Base64-encoded image') image: Optional[str] = Field(None, description='Base64 or URL of input image')
duration: Optional[str] = None duration: Optional[str] = Field(None, description='5 or 10 seconds')
resolution: Optional[str] = None

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass
from typing import Any, Optional from typing import Any, Optional
import httpx import httpx
@@ -11,6 +12,13 @@ logger = logging.getLogger(__name__)
_client: Optional[httpx.AsyncClient] = None _client: Optional[httpx.AsyncClient] = None
@dataclass
class TaskResult:
"""Wraps a Freepik API response with the status polling path."""
data: dict
status_path: str
def get_client() -> httpx.AsyncClient: def get_client() -> httpx.AsyncClient:
if _client is None: if _client is None:
raise RuntimeError('Freepik client not initialized') raise RuntimeError('Freepik client not initialized')
@@ -80,268 +88,325 @@ async def _request(
) )
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: def _strip_none(d: dict) -> dict:
"""Remove None values from a dict for clean API payloads.""" """Remove None values from a dict for clean API payloads."""
return {k: v for k, v in d.items() if v is not None} return {k: v for k, v in d.items() if v is not None}
def _extract_task_id(result: dict) -> str:
"""Extract task_id from Freepik response (always under data.task_id)."""
data = result.get('data', result)
if isinstance(data, dict):
return str(data.get('task_id', ''))
return ''
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Image generation # Image generation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_MYSTIC_PATH = '/v1/ai/mystic'
async def generate_mystic( async def generate_mystic(
prompt: str, prompt: str,
negative_prompt: Optional[str] = None,
resolution: Optional[str] = None, resolution: Optional[str] = None,
styling: Optional[dict] = None, aspect_ratio: Optional[str] = None,
model: Optional[str] = None,
seed: Optional[int] = None, seed: Optional[int] = None,
num_images: Optional[int] = None, styling: Optional[dict] = None,
) -> dict: structure_reference: Optional[str] = None,
style_reference: Optional[str] = None,
) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'prompt': prompt, 'prompt': prompt,
'negative_prompt': negative_prompt,
'resolution': resolution, 'resolution': resolution,
'styling': styling, 'aspect_ratio': aspect_ratio,
'model': model,
'seed': seed, 'seed': seed,
'num_images': num_images, 'styling': styling,
'structure_reference': structure_reference,
'style_reference': style_reference,
}) })
return await _request('POST', '/v1/ai/mystic', json=payload) result = await _request('POST', _MYSTIC_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_MYSTIC_PATH}/{task_id}')
_FLUX_DEV_PATH = '/v1/ai/text-to-image/flux-dev'
async def generate_flux_dev( async def generate_flux_dev(
prompt: str, prompt: str,
image: Optional[str] = None, aspect_ratio: Optional[str] = None,
guidance_scale: Optional[float] = None, styling: Optional[dict] = None,
num_images: Optional[int] = None,
seed: Optional[int] = None, seed: Optional[int] = None,
) -> dict: ) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'prompt': prompt, 'prompt': prompt,
'image': image, 'aspect_ratio': aspect_ratio,
'guidance_scale': guidance_scale, 'styling': styling,
'num_images': num_images,
'seed': seed, 'seed': seed,
}) })
return await _request('POST', '/v1/ai/text-to-image/flux-dev', json=payload) result = await _request('POST', _FLUX_DEV_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_FLUX_DEV_PATH}/{task_id}')
_FLUX_PRO_PATH = '/v1/ai/text-to-image/flux-pro-v1-1'
async def generate_flux_pro( async def generate_flux_pro(
prompt: str, prompt: str,
image: Optional[str] = None, aspect_ratio: Optional[str] = None,
guidance_scale: Optional[float] = None, styling: Optional[dict] = None,
seed: Optional[int] = None, seed: Optional[int] = None,
) -> dict: ) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'prompt': prompt, 'prompt': prompt,
'image': image, 'aspect_ratio': aspect_ratio,
'guidance_scale': guidance_scale, 'styling': styling,
'seed': seed, 'seed': seed,
}) })
return await _request('POST', '/v1/ai/text-to-image/flux-pro-1.1', json=payload) result = await _request('POST', _FLUX_PRO_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_FLUX_PRO_PATH}/{task_id}')
_SEEDREAM_PATH = '/v1/ai/text-to-image/seedream'
async def generate_seedream( async def generate_seedream(
prompt: str, prompt: str,
image: Optional[str] = None,
aspect_ratio: Optional[str] = None, aspect_ratio: Optional[str] = None,
num_images: Optional[int] = None,
seed: Optional[int] = None, seed: Optional[int] = None,
) -> dict: ) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'prompt': prompt, 'prompt': prompt,
'image': image,
'aspect_ratio': aspect_ratio, 'aspect_ratio': aspect_ratio,
'num_images': num_images,
'seed': seed, 'seed': seed,
}) })
return await _request('POST', '/v1/ai/text-to-image/seedream', json=payload) result = await _request('POST', _SEEDREAM_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_SEEDREAM_PATH}/{task_id}')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Video generation # Video generation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_KLING_O1_PRO_PATH = '/v1/ai/image-to-video/kling-o1-pro'
_KLING_O1_STATUS_PATH = '/v1/ai/image-to-video/kling-o1'
async def generate_video_kling( async def generate_video_kling(
image: str, first_frame: Optional[str] = None,
last_frame: Optional[str] = None,
prompt: Optional[str] = None, prompt: Optional[str] = None,
duration: Optional[str] = None, duration: Optional[int] = None,
aspect_ratio: Optional[str] = None, aspect_ratio: Optional[str] = None,
) -> dict: ) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'image': image, 'first_frame': first_frame,
'last_frame': last_frame,
'prompt': prompt, 'prompt': prompt,
'duration': duration, 'duration': duration,
'aspect_ratio': aspect_ratio, 'aspect_ratio': aspect_ratio,
}) })
return await _request('POST', '/v1/ai/image-to-video/kling', json=payload) result = await _request('POST', _KLING_O1_PRO_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_KLING_O1_STATUS_PATH}/{task_id}')
_MINIMAX_1080P_PATH = '/v1/ai/image-to-video/minimax-hailuo-02-1080p'
async def generate_video_minimax( async def generate_video_minimax(
prompt: str, prompt: str,
first_frame_image: Optional[str] = None, first_frame_image: Optional[str] = None,
subject_reference: Optional[str] = None, last_frame_image: Optional[str] = None,
) -> dict: ) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'prompt': prompt, 'prompt': prompt,
'first_frame_image': first_frame_image, 'first_frame_image': first_frame_image,
'subject_reference': subject_reference, 'last_frame_image': last_frame_image,
}) })
return await _request('POST', '/v1/ai/image-to-video/minimax', json=payload) result = await _request('POST', _MINIMAX_1080P_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_MINIMAX_1080P_PATH}/{task_id}')
_SEEDANCE_PRO_1080P_PATH = '/v1/ai/image-to-video/seedance-pro-1080p'
async def generate_video_seedance( async def generate_video_seedance(
prompt: str, prompt: str,
image: Optional[str] = None, image: Optional[str] = None,
duration: Optional[str] = None, duration: Optional[str] = None,
resolution: Optional[str] = None, ) -> TaskResult:
) -> dict:
payload = _strip_none({ payload = _strip_none({
'prompt': prompt, 'prompt': prompt,
'image': image, 'image': image,
'duration': duration, 'duration': duration,
'resolution': resolution,
}) })
return await _request('POST', '/v1/ai/image-to-video/seedance', json=payload) result = await _request('POST', _SEEDANCE_PRO_1080P_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_SEEDANCE_PRO_1080P_PATH}/{task_id}')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Image editing # Image editing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_UPSCALER_PATH = '/v1/ai/image-upscaler'
async def upscale_creative( async def upscale_creative(
image: str, image: str,
prompt: Optional[str] = None, prompt: Optional[str] = None,
scale: Optional[int] = None, scale_factor: Optional[str] = None,
creativity: Optional[float] = None, creativity: Optional[int] = None,
resemblance: Optional[float] = None, resemblance: Optional[int] = None,
) -> dict: optimized_for: Optional[str] = None,
) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'image': image, 'image': image,
'prompt': prompt, 'prompt': prompt,
'scale': scale, 'scale_factor': scale_factor,
'creativity': creativity, 'creativity': creativity,
'resemblance': resemblance, 'resemblance': resemblance,
'optimized_for': optimized_for,
}) })
return await _request('POST', '/v1/ai/upscale/creative', json=payload) result = await _request('POST', _UPSCALER_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_UPSCALER_PATH}/{task_id}')
_UPSCALER_PRECISION_PATH = '/v1/ai/image-upscaler-precision'
async def upscale_precision( async def upscale_precision(
image: str, image: str,
scale: Optional[int] = None, scale_factor: Optional[str] = None,
) -> dict: ) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'image': image, 'image': image,
'scale': scale, 'scale_factor': scale_factor,
}) })
return await _request('POST', '/v1/ai/upscale/precision', json=payload) result = await _request('POST', _UPSCALER_PRECISION_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_UPSCALER_PRECISION_PATH}/{task_id}')
_RELIGHT_PATH = '/v1/ai/image-relight'
async def relight_image( async def relight_image(
image: str, image: str,
prompt: Optional[str] = None, prompt: Optional[str] = None,
light_source: Optional[str] = None, transfer_light_from_reference_image: Optional[str] = None,
intensity: Optional[float] = None, light_transfer_strength: Optional[int] = None,
) -> dict: ) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'image': image, 'image': image,
'prompt': prompt, 'prompt': prompt,
'light_source': light_source, 'transfer_light_from_reference_image': transfer_light_from_reference_image,
'intensity': intensity, 'light_transfer_strength': light_transfer_strength,
}) })
return await _request('POST', '/v1/ai/relight', json=payload) result = await _request('POST', _RELIGHT_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_RELIGHT_PATH}/{task_id}')
_STYLE_TRANSFER_PATH = '/v1/ai/image-style-transfer'
async def style_transfer( async def style_transfer(
image: str, image: str,
style_reference: str, reference_image: str,
strength: Optional[float] = None, prompt: Optional[str] = None,
) -> dict: style_strength: Optional[int] = None,
structure_strength: Optional[int] = None,
) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'image': image, 'image': image,
'style_reference': style_reference, 'reference_image': reference_image,
'strength': strength, 'prompt': prompt,
'style_strength': style_strength,
'structure_strength': structure_strength,
}) })
return await _request('POST', '/v1/ai/style-transfer', json=payload) result = await _request('POST', _STYLE_TRANSFER_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_STYLE_TRANSFER_PATH}/{task_id}')
_EXPAND_PATH = '/v1/ai/image-expand/flux-pro'
async def expand_image( async def expand_image(
image: str, image: str,
prompt: Optional[str] = None, prompt: Optional[str] = None,
direction: Optional[str] = None, ) -> TaskResult:
) -> dict:
payload = _strip_none({ payload = _strip_none({
'image': image, 'image': image,
'prompt': prompt, 'prompt': prompt,
'direction': direction,
}) })
return await _request('POST', '/v1/ai/expand', json=payload) result = await _request('POST', _EXPAND_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_EXPAND_PATH}/{task_id}')
_INPAINT_PATH = '/v1/ai/ideogram-image-edit'
async def inpaint( async def inpaint(
image: str, image: str,
mask: str, mask: str,
prompt: str, prompt: str,
) -> dict: ) -> TaskResult:
payload = { payload = {
'image': image, 'image': image,
'mask': mask, 'mask': mask,
'prompt': prompt, 'prompt': prompt,
} }
return await _request('POST', '/v1/ai/inpaint', json=payload) result = await _request('POST', _INPAINT_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_INPAINT_PATH}/{task_id}')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Utilities # Utilities
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def remove_background(image: str) -> bytes: _REMOVE_BG_PATH = '/v1/ai/beta/remove-background'
return await _request_raw('POST', '/v1/ai/remove-background', json={'image': image})
async def remove_background(image_url: str) -> dict:
"""Remove background. Takes an image URL (not base64). Returns URLs."""
return await _request(
'POST', _REMOVE_BG_PATH,
data={'image_url': image_url},
)
_CLASSIFIER_PATH = '/v1/ai/classifier/image'
async def classify_image(image: str) -> dict: async def classify_image(image: str) -> dict:
return await _request('POST', '/v1/ai/classifier', json={'image': image}) """Classify whether image is AI-generated. Returns data: [{class_name, probability}]."""
return await _request('POST', _CLASSIFIER_PATH, json={'image': image})
async def isolate_audio(audio: str) -> dict: _AUDIO_ISOLATION_PATH = '/v1/ai/audio-isolation'
return await _request('POST', '/v1/ai/audio-isolate', json={'audio': audio})
async def isolate_audio(audio: str) -> TaskResult:
result = await _request('POST', _AUDIO_ISOLATION_PATH, json={'audio': audio})
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_AUDIO_ISOLATION_PATH}/{task_id}')
_ICON_PATH = '/v1/ai/text-to-icon'
async def generate_icon( async def generate_icon(
@@ -349,29 +414,32 @@ async def generate_icon(
color: Optional[str] = None, color: Optional[str] = None,
shape: Optional[str] = None, shape: Optional[str] = None,
style: Optional[str] = None, style: Optional[str] = None,
) -> dict: ) -> TaskResult:
payload = _strip_none({ payload = _strip_none({
'prompt': prompt, 'prompt': prompt,
'color': color, 'color': color,
'shape': shape, 'shape': shape,
'style': style, 'style': style,
}) })
return await _request('POST', '/v1/ai/icon', json=payload) result = await _request('POST', _ICON_PATH, json=payload)
task_id = _extract_task_id(result)
return TaskResult(data=result, status_path=f'{_ICON_PATH}/{task_id}')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Task management # Task management
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def get_task_status(task_id: str) -> dict: async def get_task_status(status_path: str) -> dict:
return await _request('GET', f'/v1/ai/tasks/{task_id}') """Poll a task's status using its per-endpoint GET path."""
return await _request('GET', status_path)
async def check_api_key() -> bool: async def check_api_key() -> bool:
"""Verify the API key is valid by making a lightweight request.""" """Verify the API key is valid by making a lightweight request."""
try: try:
client = get_client() client = get_client()
response = await client.get('/v1/ai/tasks', params={'limit': 1}) response = await client.get('/v1/ai/mystic', params={'per_page': 1})
return response.status_code != 401 return response.status_code != 401
except Exception: except Exception:
return False return False

View File

@@ -15,13 +15,21 @@ _tasks: dict[str, dict] = {}
_poll_tasks: dict[str, asyncio.Task] = {} _poll_tasks: dict[str, asyncio.Task] = {}
def submit(freepik_task_id: str, metadata: Optional[dict] = None) -> str: def submit(freepik_task_id: str, status_path: str, metadata: Optional[dict] = None) -> str:
"""Register a Freepik task and start background polling.""" """Register a Freepik task and start background polling.
Args:
freepik_task_id: The task_id returned by Freepik.
status_path: The per-endpoint GET path for polling, e.g.
'/v1/ai/text-to-image/flux-dev/{task-id}'.
metadata: Optional metadata to attach to the task.
"""
internal_id = str(uuid.uuid4()) internal_id = str(uuid.uuid4())
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
_tasks[internal_id] = { _tasks[internal_id] = {
'task_id': internal_id, 'task_id': internal_id,
'freepik_task_id': freepik_task_id, 'freepik_task_id': freepik_task_id,
'status_path': status_path,
'status': TaskStatus.pending, 'status': TaskStatus.pending,
'created_at': now, 'created_at': now,
'updated_at': now, 'updated_at': now,
@@ -55,7 +63,6 @@ def list_tasks(
def delete_task(task_id: str) -> bool: def delete_task(task_id: str) -> bool:
if task_id not in _tasks: if task_id not in _tasks:
return False return False
# Cancel polling if active
poll = _poll_tasks.pop(task_id, None) poll = _poll_tasks.pop(task_id, None)
if poll and not poll.done(): if poll and not poll.done():
poll.cancel() poll.cancel()
@@ -71,12 +78,12 @@ def active_count() -> int:
async def _poll_loop(internal_id: str): async def _poll_loop(internal_id: str):
"""Poll Freepik API until the task completes or times out.""" """Poll Freepik API using the per-endpoint status path until done."""
task = _tasks.get(internal_id) task = _tasks.get(internal_id)
if not task: if not task:
return return
freepik_id = task['freepik_task_id'] status_path = task['status_path']
elapsed = 0 elapsed = 0
try: try:
@@ -85,30 +92,26 @@ async def _poll_loop(internal_id: str):
elapsed += settings.task_poll_interval_seconds elapsed += settings.task_poll_interval_seconds
try: try:
result = await freepik_client.get_task_status(freepik_id) result = await freepik_client.get_task_status(status_path)
except Exception as exc: except Exception as exc:
logger.warning(f'Poll error for {internal_id}: {exc}') logger.warning(f'Poll error for {internal_id}: {exc}')
continue continue
data = result.get('data', result) data = result.get('data', result)
fp_status = data.get('status', '') fp_status = str(data.get('status', '')).upper()
task['updated_at'] = datetime.now(timezone.utc) task['updated_at'] = datetime.now(timezone.utc)
if fp_status in ('IN_PROGRESS', 'PROCESSING', 'processing'): if fp_status in ('CREATED', 'IN_PROGRESS', 'PROCESSING'):
task['status'] = TaskStatus.processing task['status'] = TaskStatus.processing
task['progress'] = data.get('progress')
continue continue
if fp_status in ('COMPLETED', 'completed', 'done'): if fp_status == 'COMPLETED':
task['status'] = TaskStatus.completed task['status'] = TaskStatus.completed
task['progress'] = 1.0 task['progress'] = 1.0
# Extract result URL from various response shapes # Freepik returns results in data.generated[] (list of URLs)
result_url = ( generated = data.get('generated', [])
data.get('result_url') result_url = generated[0] if generated else None
or data.get('output', {}).get('url')
or _extract_first_url(data)
)
task['result_url'] = result_url task['result_url'] = result_url
if result_url: if result_url:
try: try:
@@ -120,7 +123,7 @@ async def _poll_loop(internal_id: str):
logger.info(f'Task {internal_id} completed') logger.info(f'Task {internal_id} completed')
return return
if fp_status in ('FAILED', 'failed', 'error'): if fp_status == 'FAILED':
task['status'] = TaskStatus.failed task['status'] = TaskStatus.failed
task['error'] = data.get('error', data.get('message', 'Unknown error')) task['error'] = data.get('error', data.get('message', 'Unknown error'))
logger.warning(f'Task {internal_id} failed: {task["error"]}') logger.warning(f'Task {internal_id} failed: {task["error"]}')
@@ -141,23 +144,6 @@ async def _poll_loop(internal_id: str):
_poll_tasks.pop(internal_id, None) _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): def handle_webhook_completion(freepik_task_id: str, result_data: dict):
"""Called when a webhook notification arrives for a completed task.""" """Called when a webhook notification arrives for a completed task."""
for task in _tasks.values(): for task in _tasks.values():
@@ -165,13 +151,8 @@ def handle_webhook_completion(freepik_task_id: str, result_data: dict):
task['status'] = TaskStatus.completed task['status'] = TaskStatus.completed
task['progress'] = 1.0 task['progress'] = 1.0
task['updated_at'] = datetime.now(timezone.utc) task['updated_at'] = datetime.now(timezone.utc)
result_url = ( generated = result_data.get('generated', [])
result_data.get('result_url') task['result_url'] = generated[0] if generated else None
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) poll = _poll_tasks.pop(task['task_id'], None)
if poll and not poll.done(): if poll and not poll.done():
poll.cancel() poll.cancel()