feat: initial Freepik AI CLI
Sophisticated Python CLI for generating and manipulating images and video via the Freepik API, built with typer + rich. Commands: - generate-image: text-to-image with 8 models (flux-2-pro, mystic, seedream, etc.) - generate-video: image-to-video with 7 models (kling, minimax, runway, etc.) - generate-icon: text-to-icon in solid/outline/color/flat/sticker styles - upscale-image: 3 modes (precision-v2, precision, creative) + 2x/4x scale - upscale-video: standard/turbo modes - expand-image: outpainting with per-side pixel offsets - relight: AI-controlled relighting (Premium) - style-transfer: artistic style application (Premium) - describe-image: reverse-engineer an image into a prompt - config set/get/show/reset: configuration management Features: Rich Live polling panel, exponential backoff, --wait/--no-wait, auto-timestamped output filenames, streaming download with progress bar, FREEPIK_API_KEY env var support, venv-based setup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""Configuration management — env vars, config file, and defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import toml
|
||||
from platformdirs import user_config_dir
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
CONFIG_DIR = Path(user_config_dir("freepik-cli"))
|
||||
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||
|
||||
|
||||
class FreepikConfig(BaseSettings):
|
||||
"""
|
||||
Configuration with priority (highest to lowest):
|
||||
1. CLI --api-key flag (handled in commands directly)
|
||||
2. FREEPIK_* environment variables
|
||||
3. ~/.config/freepik-cli/config.toml
|
||||
4. Defaults below
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="FREEPIK_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
api_key: Optional[str] = None
|
||||
base_url: str = "https://api.freepik.com"
|
||||
default_output_dir: str = "."
|
||||
default_image_model: str = "flux-2-pro"
|
||||
default_video_model: str = "kling-o1-pro"
|
||||
default_upscale_mode: str = "precision-v2"
|
||||
poll_timeout: int = 600
|
||||
poll_max_interval: int = 15
|
||||
show_banner: bool = True
|
||||
|
||||
@field_validator("api_key", mode="before")
|
||||
@classmethod
|
||||
def strip_api_key(cls, v: Optional[str]) -> Optional[str]:
|
||||
return v.strip() if isinstance(v, str) else v
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "FreepikConfig":
|
||||
"""Load from config file, then overlay environment variables."""
|
||||
file_data: dict = {}
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
file_data = toml.load(CONFIG_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
return cls(**file_data)
|
||||
|
||||
def save(self, exclude_keys: set[str] | None = None) -> None:
|
||||
"""Persist non-sensitive config to disk."""
|
||||
exclude_keys = (exclude_keys or set()) | {"api_key"}
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = self.model_dump(exclude=exclude_keys, exclude_none=True)
|
||||
# Stringify paths
|
||||
for k, v in data.items():
|
||||
if isinstance(v, Path):
|
||||
data[k] = str(v)
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
toml.dump(data, f)
|
||||
|
||||
def to_display_dict(self) -> dict:
|
||||
"""Return all settings as a displayable dict (keeps api_key for masking)."""
|
||||
d = self.model_dump()
|
||||
return {k: v for k, v in d.items()}
|
||||
|
||||
def set_value(self, key: str, value: str) -> None:
|
||||
"""Update a single config key and save."""
|
||||
allowed = {
|
||||
"base_url", "default_output_dir", "default_image_model",
|
||||
"default_video_model", "default_upscale_mode", "poll_timeout",
|
||||
"poll_max_interval", "show_banner",
|
||||
}
|
||||
if key not in allowed:
|
||||
raise ValueError(
|
||||
f"Key '{key}' is not configurable via this command. "
|
||||
f"Use the FREEPIK_API_KEY environment variable to set the API key."
|
||||
)
|
||||
current = self.model_dump()
|
||||
if key in ("poll_timeout", "poll_max_interval"):
|
||||
current[key] = int(value)
|
||||
elif key == "show_banner":
|
||||
current[key] = value.lower() in ("true", "1", "yes")
|
||||
else:
|
||||
current[key] = value
|
||||
updated = FreepikConfig(**{k: v for k, v in current.items() if v is not None})
|
||||
updated.save()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Rich console singleton, theme, banner, and all display helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import rich.box
|
||||
from rich.align import Align
|
||||
from rich.columns import Columns
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.rule import Rule
|
||||
from rich.syntax import Syntax
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.theme import Theme
|
||||
|
||||
FREEPIK_THEME = Theme(
|
||||
{
|
||||
"info": "bold cyan",
|
||||
"success": "bold green",
|
||||
"warning": "bold yellow",
|
||||
"error": "bold red",
|
||||
"model": "bold magenta",
|
||||
"taskid": "bold blue",
|
||||
"path": "bold white underline",
|
||||
"dim.label": "dim white",
|
||||
"highlight": "bold magenta",
|
||||
"brand": "bold magenta",
|
||||
}
|
||||
)
|
||||
|
||||
console = Console(theme=FREEPIK_THEME, highlight=True)
|
||||
err_console = Console(stderr=True, theme=FREEPIK_THEME)
|
||||
|
||||
BANNER = """\
|
||||
[bold magenta] ███████╗██████╗ ███████╗███████╗██████╗ ██╗██╗ ██╗[/bold magenta]
|
||||
[bold magenta] ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██║██║ ██╔╝[/bold magenta]
|
||||
[bold magenta] █████╗ ██████╔╝█████╗ █████╗ ██████╔╝██║█████╔╝ [/bold magenta]
|
||||
[bold magenta] ██╔══╝ ██╔══██╗██╔══╝ ██╔══╝ ██╔═══╝ ██║██╔═██╗ [/bold magenta]
|
||||
[bold magenta] ██║ ██║ ██║███████╗███████╗██║ ██║██║ ██╗[/bold magenta]
|
||||
[bold magenta] ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝[/bold magenta]
|
||||
[dim] AI Media Generation CLI • v0.1.0[/dim]"""
|
||||
|
||||
|
||||
def print_banner() -> None:
|
||||
console.print()
|
||||
console.print(Align.center(Text.from_markup(BANNER)))
|
||||
console.print(Rule(style="magenta dim"))
|
||||
console.print()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationResult:
|
||||
task_id: str
|
||||
model: str
|
||||
output_path: Path
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
seed: Optional[int] = None
|
||||
duration: Optional[str] = None
|
||||
task_type: str = "image"
|
||||
|
||||
|
||||
def print_result(result: GenerationResult) -> None:
|
||||
table = Table(
|
||||
show_header=False,
|
||||
box=rich.box.SIMPLE,
|
||||
padding=(0, 1),
|
||||
show_edge=False,
|
||||
)
|
||||
table.add_column(style="dim.label", width=16, no_wrap=True)
|
||||
table.add_column(style="bold", overflow="fold")
|
||||
|
||||
table.add_row("Model", f"[model]{result.model}[/model]")
|
||||
table.add_row("Task ID", f"[taskid]{result.task_id[:16]}…[/taskid]")
|
||||
|
||||
if result.width and result.height:
|
||||
table.add_row("Dimensions", f"{result.width} × {result.height} px")
|
||||
if result.duration:
|
||||
table.add_row("Duration", f"{result.duration}s")
|
||||
if result.seed is not None:
|
||||
table.add_row("Seed", str(result.seed))
|
||||
else:
|
||||
table.add_row("Seed", "[dim]random[/dim]")
|
||||
|
||||
table.add_row("Saved to", f"[path]{result.output_path}[/path]")
|
||||
|
||||
title_map = {
|
||||
"image": "[success] Image Generated [/success]",
|
||||
"video": "[success] Video Generated [/success]",
|
||||
"upscale-image": "[success] Image Upscaled [/success]",
|
||||
"upscale-video": "[success] Video Upscaled [/success]",
|
||||
"icon": "[success] Icon Generated [/success]",
|
||||
"expand": "[success] Image Expanded [/success]",
|
||||
"describe": "[success] Image Described [/success]",
|
||||
}
|
||||
title = title_map.get(result.task_type, "[success] Done [/success]")
|
||||
|
||||
console.print(Panel(table, title=title, border_style="green", padding=(1, 2)))
|
||||
|
||||
|
||||
def print_describe_result(task_id: str, prompt_text: str, output_path: Optional[Path] = None) -> None:
|
||||
content = Text(prompt_text, style="italic")
|
||||
footer = ""
|
||||
if output_path:
|
||||
footer = f"\n\n[dim]Saved to:[/dim] [path]{output_path}[/path]"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
Text.from_markup(f"{prompt_text}{footer}"),
|
||||
title="[success] Image Description [/success]",
|
||||
subtitle=f"[dim]Task: {task_id[:16]}…[/dim]",
|
||||
border_style="green",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_no_wait(task_id: str, task_type: str, model: str) -> None:
|
||||
console.print(
|
||||
Panel(
|
||||
Text.from_markup(
|
||||
f"[info]Task submitted successfully.[/info]\n\n"
|
||||
f"[dim.label]Task ID:[/dim.label] [taskid]{task_id}[/taskid]\n"
|
||||
f"[dim.label]Type:[/dim.label] {task_type}\n"
|
||||
f"[dim.label]Model:[/dim.label] [model]{model}[/model]\n\n"
|
||||
f"[dim]The task is processing asynchronously. Results will be available\n"
|
||||
f"on the Freepik dashboard when complete.[/dim]"
|
||||
),
|
||||
title="⏳ Task Queued",
|
||||
border_style="cyan",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_error(message: str, hint: Optional[str] = None) -> None:
|
||||
body = f"[error]{message}[/error]"
|
||||
if hint:
|
||||
body += f"\n\n[dim]Hint:[/dim] {hint}"
|
||||
err_console.print(
|
||||
Panel(
|
||||
Text.from_markup(body),
|
||||
title="[error] Error [/error]",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_warning(message: str) -> None:
|
||||
console.print(
|
||||
Panel(
|
||||
Text.from_markup(f"[warning]{message}[/warning]"),
|
||||
title="[warning] Warning [/warning]",
|
||||
border_style="yellow",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_config_table(config_dict: dict, masked_keys: set[str] | None = None) -> None:
|
||||
masked_keys = masked_keys or {"api_key"}
|
||||
table = Table(
|
||||
title="[brand]Freepik CLI Configuration[/brand]",
|
||||
show_header=True,
|
||||
header_style="bold magenta",
|
||||
border_style="magenta",
|
||||
box=rich.box.ROUNDED,
|
||||
padding=(0, 1),
|
||||
)
|
||||
table.add_column("Key", style="dim.label", width=28)
|
||||
table.add_column("Value", style="bold", overflow="fold")
|
||||
|
||||
for key, value in config_dict.items():
|
||||
if key in masked_keys and value:
|
||||
display = f"[dim]{'*' * 8}{str(value)[-4:]}[/dim]" if len(str(value)) > 4 else "[dim]****[/dim]"
|
||||
elif value is None or value == "":
|
||||
display = "[dim]not set[/dim]"
|
||||
else:
|
||||
display = str(value)
|
||||
table.add_row(key, display)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_config_toml(config_dict: dict, masked_keys: set[str] | None = None) -> None:
|
||||
masked_keys = masked_keys or {"api_key"}
|
||||
lines = []
|
||||
for key, value in config_dict.items():
|
||||
if key in masked_keys:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
lines.append(f'{key} = "{value}"')
|
||||
elif isinstance(value, bool):
|
||||
lines.append(f"{key} = {str(value).lower()}")
|
||||
else:
|
||||
lines.append(f"{key} = {value}")
|
||||
|
||||
toml_str = "\n".join(lines)
|
||||
console.print(
|
||||
Panel(
|
||||
Syntax(toml_str, "toml", theme="monokai", background_color="default"),
|
||||
title="[brand]~/.config/freepik-cli/config.toml[/brand]",
|
||||
border_style="magenta",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""File I/O utilities: base64 encoding, output path generation, download."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from rich.console import Console
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
DownloadColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TimeElapsedColumn,
|
||||
TransferSpeedColumn,
|
||||
)
|
||||
|
||||
|
||||
def image_to_base64(path: Path) -> str:
|
||||
"""Read an image file and return a base64-encoded string."""
|
||||
suffix = path.suffix.lower().lstrip(".")
|
||||
mime_map = {
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
}
|
||||
mime = mime_map.get(suffix, "image/jpeg")
|
||||
with open(path, "rb") as f:
|
||||
encoded = base64.b64encode(f.read()).decode()
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def video_to_base64(path: Path) -> str:
|
||||
"""Read a video file and return a base64-encoded string."""
|
||||
suffix = path.suffix.lower().lstrip(".")
|
||||
mime_map = {
|
||||
"mp4": "video/mp4",
|
||||
"mov": "video/quicktime",
|
||||
"avi": "video/x-msvideo",
|
||||
"webm": "video/webm",
|
||||
}
|
||||
mime = mime_map.get(suffix, "video/mp4")
|
||||
with open(path, "rb") as f:
|
||||
encoded = base64.b64encode(f.read()).decode()
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def auto_output_path(task_type: str, model: str, ext: str = "jpg", output_dir: str = ".") -> Path:
|
||||
"""Generate a timestamped output filename."""
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_model = model.replace("/", "-").replace(" ", "-")
|
||||
filename = f"freepik_{task_type}_{safe_model}_{ts}.{ext}"
|
||||
return Path(output_dir) / filename
|
||||
|
||||
|
||||
def save_from_url(url: str, dest: Path, console: Console) -> None:
|
||||
"""Stream-download a URL to a file, displaying a Rich progress bar."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(style="bold magenta"),
|
||||
"[progress.description]{task.description}",
|
||||
BarColumn(bar_width=30, style="magenta", complete_style="green"),
|
||||
"[progress.percentage]{task.percentage:>3.0f}%",
|
||||
DownloadColumn(),
|
||||
TransferSpeedColumn(),
|
||||
TimeElapsedColumn(),
|
||||
console=console,
|
||||
transient=True,
|
||||
) as progress:
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=120) as r:
|
||||
r.raise_for_status()
|
||||
total = int(r.headers.get("content-length", 0)) or None
|
||||
task = progress.add_task(
|
||||
f"[dim]Saving[/dim] [bold]{dest.name}[/bold]",
|
||||
total=total,
|
||||
)
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in r.iter_bytes(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
progress.advance(task, len(chunk))
|
||||
|
||||
|
||||
def get_image_dimensions(path: Path) -> tuple[Optional[int], Optional[int]]:
|
||||
"""Return (width, height) of an image using Pillow."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as img:
|
||||
return img.width, img.height
|
||||
except Exception:
|
||||
return None, None
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Async task polling with beautiful Rich Live display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Literal, Tuple
|
||||
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.spinner import Spinner
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
TaskStatus = Literal["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED", "CANCELLED", "CREATED"]
|
||||
|
||||
STATUS_COLORS: dict[str, str] = {
|
||||
"CREATED": "yellow",
|
||||
"PENDING": "yellow",
|
||||
"IN_PROGRESS": "cyan",
|
||||
"COMPLETED": "green",
|
||||
"FAILED": "red",
|
||||
"CANCELLED": "dim red",
|
||||
}
|
||||
|
||||
STATUS_ICONS: dict[str, str] = {
|
||||
"CREATED": "○",
|
||||
"PENDING": "○",
|
||||
"IN_PROGRESS": "◐",
|
||||
"COMPLETED": "●",
|
||||
"FAILED": "✗",
|
||||
"CANCELLED": "✗",
|
||||
}
|
||||
|
||||
TASK_TYPE_LABELS: dict[str, str] = {
|
||||
"image": "Image Generation",
|
||||
"video": "Video Generation",
|
||||
"upscale-image": "Image Upscaling",
|
||||
"upscale-video": "Video Upscaling",
|
||||
"icon": "Icon Generation",
|
||||
"expand": "Image Expansion",
|
||||
"describe": "Image Analysis",
|
||||
"relight": "Image Relighting",
|
||||
"style-transfer": "Style Transfer",
|
||||
}
|
||||
|
||||
|
||||
class FreepikTaskError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FreepikTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollConfig:
|
||||
initial_delay: float = 2.0
|
||||
min_interval: float = 2.0
|
||||
max_interval: float = 15.0
|
||||
backoff_factor: float = 1.5
|
||||
max_wait: float = 600.0
|
||||
task_type: str = "image"
|
||||
|
||||
|
||||
def _render_panel(
|
||||
task_id: str,
|
||||
status: str,
|
||||
elapsed: float,
|
||||
task_type: str,
|
||||
extra_info: dict[str, str] | None = None,
|
||||
) -> Panel:
|
||||
color = STATUS_COLORS.get(status.upper(), "white")
|
||||
icon = STATUS_ICONS.get(status.upper(), "?")
|
||||
label = TASK_TYPE_LABELS.get(task_type, task_type.replace("-", " ").title())
|
||||
|
||||
grid = Table.grid(padding=(0, 2))
|
||||
grid.add_column(style="dim", width=14, no_wrap=True)
|
||||
grid.add_column(overflow="fold")
|
||||
|
||||
spinner = Spinner("dots", style="bold magenta")
|
||||
|
||||
# Header row with spinner
|
||||
header = Text()
|
||||
header.append(f"{label}", style="bold white")
|
||||
grid.add_row(spinner, header)
|
||||
grid.add_row("", "") # spacer
|
||||
|
||||
short_id = task_id[:12] + "…" if len(task_id) > 12 else task_id
|
||||
grid.add_row("Task ID", f"[bold blue]{short_id}[/bold blue]")
|
||||
grid.add_row(
|
||||
"Status",
|
||||
f"[{color}]{icon} {status.replace('_', ' ')}[/{color}]",
|
||||
)
|
||||
|
||||
mins, secs = divmod(int(elapsed), 60)
|
||||
time_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
|
||||
grid.add_row("Elapsed", f"[dim]{time_str}[/dim]")
|
||||
|
||||
if extra_info:
|
||||
for k, v in extra_info.items():
|
||||
grid.add_row(k, v)
|
||||
|
||||
return Panel(
|
||||
grid,
|
||||
title="[bold magenta]~ Freepik AI ~[/bold magenta]",
|
||||
border_style="magenta",
|
||||
padding=(1, 2),
|
||||
width=52,
|
||||
)
|
||||
|
||||
|
||||
def poll_task(
|
||||
check_fn: Callable[[str], Tuple[str, dict[str, Any]]],
|
||||
task_id: str,
|
||||
config: PollConfig,
|
||||
console: Console,
|
||||
extra_info: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Poll until COMPLETED or FAILED, displaying a live status panel.
|
||||
|
||||
Args:
|
||||
check_fn: Callable(task_id) → (status_str, raw_response_dict)
|
||||
task_id: The task ID to poll
|
||||
config: Polling configuration
|
||||
console: Rich console instance
|
||||
extra_info: Extra rows to display in the status panel
|
||||
|
||||
Returns:
|
||||
The raw response dict when status is COMPLETED
|
||||
"""
|
||||
start = time.monotonic()
|
||||
interval = config.initial_delay
|
||||
current_status = "PENDING"
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
with Live(
|
||||
_render_panel(task_id, current_status, 0, config.task_type, extra_info),
|
||||
console=console,
|
||||
refresh_per_second=8,
|
||||
transient=False,
|
||||
) as live:
|
||||
time.sleep(config.initial_delay)
|
||||
|
||||
while True:
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
if elapsed > config.max_wait:
|
||||
live.stop()
|
||||
raise FreepikTimeoutError(
|
||||
f"Task {task_id} timed out after {config.max_wait:.0f}s"
|
||||
)
|
||||
|
||||
try:
|
||||
current_status, result = check_fn(task_id)
|
||||
except Exception as exc:
|
||||
live.stop()
|
||||
raise exc
|
||||
|
||||
live.update(
|
||||
_render_panel(task_id, current_status, elapsed, config.task_type, extra_info)
|
||||
)
|
||||
|
||||
upper = current_status.upper()
|
||||
if upper == "COMPLETED":
|
||||
live.update(
|
||||
_render_panel(task_id, "COMPLETED", elapsed, config.task_type, extra_info)
|
||||
)
|
||||
return result
|
||||
|
||||
if upper in ("FAILED", "CANCELLED"):
|
||||
live.stop()
|
||||
data = result.get("data", result)
|
||||
error_msg = (
|
||||
data.get("error", {}).get("message")
|
||||
or data.get("message")
|
||||
or f"Task ended with status {upper}"
|
||||
)
|
||||
raise FreepikTaskError(f"{upper}: {error_msg}")
|
||||
|
||||
time.sleep(interval)
|
||||
interval = min(interval * config.backoff_factor, config.max_interval)
|
||||
Reference in New Issue
Block a user