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:
2026-04-08 10:56:45 +02:00
commit f24d138ab4
24 changed files with 2511 additions and 0 deletions
View File
+96
View File
@@ -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()
+213
View File
@@ -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),
)
)
+97
View File
@@ -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
+184
View File
@@ -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)