refactor: rename project from Freepik to Magnific

Rename all identifiers, strings, file names, env vars, CLI entry point,
ASCII banner, and API endpoint to reflect the company rebrand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:06:16 +02:00
parent e013db9065
commit 941fd14ccf
24 changed files with 294 additions and 294 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("magnific-cli"))
CONFIG_FILE = CONFIG_DIR / "config.toml"
class MagnificConfig(BaseSettings):
"""
Configuration with priority (highest to lowest):
1. CLI --api-key flag (handled in commands directly)
2. MAGNIFIC_* environment variables
3. ~/.config/magnific-cli/config.toml
4. Defaults below
"""
model_config = SettingsConfigDict(
env_prefix="MAGNIFIC_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
api_key: Optional[str] = None
base_url: str = "https://api.magnific.ai"
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) -> "MagnificConfig":
"""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 MAGNIFIC_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 = MagnificConfig(**{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
MAGNIFIC_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=MAGNIFIC_THEME, highlight=True)
err_console = Console(stderr=True, theme=MAGNIFIC_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 Magnific 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]Magnific 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/magnific-cli/config.toml[/brand]",
border_style="magenta",
padding=(1, 2),
)
)
+105
View File
@@ -0,0 +1,105 @@
"""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.
Uses Pillow to detect the actual format rather than trusting the file
extension — mismatched extensions (e.g. a JPEG saved as .png) would
produce an incorrect MIME type that causes silent failures with some models.
"""
from PIL import Image
_pillow_to_mime = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"GIF": "image/gif",
"WEBP": "image/webp",
}
with Image.open(path) as img:
fmt = img.format or "JPEG"
mime = _pillow_to_mime.get(fmt, "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"magnific_{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 MagnificTaskError(Exception):
pass
class MagnificTimeoutError(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]~ Magnific 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 MagnificTimeoutError(
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 MagnificTaskError(f"{upper}: {error_msg}")
time.sleep(interval)
interval = min(interval * config.backoff_factor, config.max_interval)