commit f24d138ab422b8f7a030314db16bebccb0c88015 Author: Sebastian Krüger Date: Wed Apr 8 10:56:45 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2844a32 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Freepik API Configuration +# Get your API key at https://www.freepik.com/api +FREEPIK_API_KEY=your_api_key_here + +# Optional overrides +# FREEPIK_BASE_URL=https://api.freepik.com +# FREEPIK_POLL_TIMEOUT=600 +# FREEPIK_DEFAULT_IMAGE_MODEL=flux-2-pro +# FREEPIK_DEFAULT_VIDEO_MODEL=kling-o1-pro +# FREEPIK_DEFAULT_OUTPUT_DIR=./output +# FREEPIK_SHOW_BANNER=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcf9030 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.env +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +*.jpg +*.jpeg +*.png +*.gif +*.webp +*.mp4 +*.mov +*.avi +*.wav diff --git a/README.md b/README.md new file mode 100644 index 0000000..5634724 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Freepik AI CLI + +A sophisticated, beautiful Python CLI for generating and manipulating images and video with the [Freepik API](https://docs.freepik.com/introduction). + +## Installation + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## Quick Start + +```bash +export FREEPIK_API_KEY=your_api_key_here + +# Generate an image +freepik generate-image "a majestic mountain at sunset" --model flux-2-pro + +# Generate a video from an image +freepik generate-video photo.jpg --prompt "gentle ocean waves" --model kling-o1-pro + +# Upscale an image +freepik upscale-image photo.jpg --mode precision-v2 --scale 4x + +# Upscale a video +freepik upscale-video clip.mp4 --mode standard +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `generate-image` | Generate images from text prompts | +| `generate-video` | Animate an image into a video | +| `generate-icon` | Generate icons in various styles | +| `upscale-image` | Upscale and enhance images | +| `upscale-video` | Upscale video to higher resolution | +| `expand-image` | Outpaint / expand image borders | +| `relight` | Relight an image (Premium) | +| `style-transfer` | Apply artistic styles (Premium) | +| `describe-image` | Reverse-engineer an image into a prompt | +| `config` | Manage CLI configuration | + +## Configuration + +```bash +freepik config show +freepik config set default_image_model mystic +freepik config set default_output_dir ~/images +``` diff --git a/freepik_cli/__init__.py b/freepik_cli/__init__.py new file mode 100644 index 0000000..eaae218 --- /dev/null +++ b/freepik_cli/__init__.py @@ -0,0 +1,3 @@ +"""Freepik AI CLI — generate images, videos, and more.""" + +__version__ = "0.1.0" diff --git a/freepik_cli/api/__init__.py b/freepik_cli/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freepik_cli/api/client.py b/freepik_cli/api/client.py new file mode 100644 index 0000000..665b811 --- /dev/null +++ b/freepik_cli/api/client.py @@ -0,0 +1,109 @@ +"""Freepik HTTP client with authentication, error handling, and download support.""" + +from __future__ import annotations + +from typing import Any, Optional + +import httpx + +from freepik_cli import __version__ + +BASE_URL = "https://api.freepik.com" +DEFAULT_TIMEOUT = 60.0 + + +class FreepikAPIError(Exception): + """Raised when the Freepik API returns an error response.""" + + def __init__(self, message: str, status_code: Optional[int] = None, raw: Optional[dict] = None): + super().__init__(message) + self.status_code = status_code + self.raw = raw or {} + + @classmethod + def from_response(cls, response: httpx.Response) -> "FreepikAPIError": + try: + body = response.json() + except Exception: + body = {} + + message = ( + body.get("message") + or body.get("error", {}).get("message") + or body.get("errors", [{}])[0].get("message") + or f"HTTP {response.status_code}" + ) + + hints = { + 401: "Check your API key — set FREEPIK_API_KEY or use --api-key.", + 403: "Your plan may not support this feature. Check your Freepik subscription.", + 422: "Invalid request parameters. Check the options you provided.", + 429: "Rate limit exceeded. Please wait before retrying.", + } + hint = hints.get(response.status_code) + if hint: + message = f"{message}\n\nHint: {hint}" + + return cls(message, status_code=response.status_code, raw=body) + + +class FreepikClient: + """Thin synchronous HTTP wrapper around the Freepik API.""" + + def __init__( + self, + api_key: str, + base_url: str = BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + self._client = httpx.Client( + base_url=base_url, + headers={ + "x-freepik-api-key": api_key, + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": f"freepik-cli/{__version__}", + }, + timeout=httpx.Timeout(timeout), + ) + + def post(self, path: str, json: dict[str, Any]) -> dict[str, Any]: + try: + response = self._client.post(path, json=json) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + raise FreepikAPIError.from_response(exc.response) from exc + except httpx.RequestError as exc: + raise FreepikAPIError(f"Network error: {exc}") from exc + + def post_multipart(self, path: str, data: dict[str, Any], files: dict[str, Any]) -> dict[str, Any]: + """POST with multipart/form-data (for file uploads).""" + headers = {k: v for k, v in self._client.headers.items() if k.lower() != "content-type"} + try: + response = self._client.post(path, data=data, files=files, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + raise FreepikAPIError.from_response(exc.response) from exc + except httpx.RequestError as exc: + raise FreepikAPIError(f"Network error: {exc}") from exc + + def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + try: + response = self._client.get(path, params=params) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + raise FreepikAPIError.from_response(exc.response) from exc + except httpx.RequestError as exc: + raise FreepikAPIError(f"Network error: {exc}") from exc + + def __enter__(self) -> "FreepikClient": + return self + + def __exit__(self, *args: Any) -> None: + self._client.close() + + def close(self) -> None: + self._client.close() diff --git a/freepik_cli/api/edit.py b/freepik_cli/api/edit.py new file mode 100644 index 0000000..548f1ed --- /dev/null +++ b/freepik_cli/api/edit.py @@ -0,0 +1,98 @@ +"""Image editing API methods: expand, relight, style-transfer, icons.""" + +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from freepik_cli.api.client import FreepikClient +from freepik_cli.api.models import IconStyle, get_output_urls, get_status, get_task_id + + +class EditAPI: + def __init__(self, client: FreepikClient) -> None: + self._client = client + + # ------------------------------------------------------------------ + # Icon generation + # ------------------------------------------------------------------ + + def generate_icon( + self, + prompt: str, + style: IconStyle = IconStyle.COLOR, + num_inference_steps: int = 30, + guidance_scale: float = 7.5, + seed: Optional[int] = None, + ) -> str: + """Submit a text-to-icon task. Returns task_id.""" + payload: dict[str, Any] = { + "prompt": prompt, + "style": style.value, + "num_inference_steps": num_inference_steps, + "guidance_scale": guidance_scale, + } + if seed is not None: + payload["seed"] = seed + + raw = self._client.post("/v1/ai/text-to-icon", json=payload) + return get_task_id(raw) + + def icon_status(self, task_id: str) -> Tuple[str, dict[str, Any]]: + raw = self._client.get(f"/v1/ai/text-to-icon/{task_id}") + return get_status(raw), raw + + def render_icon(self, task_id: str, fmt: str = "png") -> str: + """Get the download URL for a completed icon in PNG or SVG format.""" + raw = self._client.post(f"/v1/ai/text-to-icon/{task_id}/render/{fmt}", json={}) + data = raw.get("data", raw) + return data.get("url") or data.get("download_url") or "" + + # ------------------------------------------------------------------ + # Relight + # ------------------------------------------------------------------ + + def relight_submit( + self, + image_b64: str, + prompt: Optional[str] = None, + style: Optional[str] = None, + ) -> str: + payload: dict[str, Any] = {"image": image_b64} + if prompt: + payload["prompt"] = prompt + if style: + payload["style"] = style + + raw = self._client.post("/v1/ai/image-relight", json=payload) + return get_task_id(raw) + + def relight_status(self, task_id: str) -> Tuple[str, dict[str, Any]]: + raw = self._client.get(f"/v1/ai/image-relight/{task_id}") + return get_status(raw), raw + + # ------------------------------------------------------------------ + # Style Transfer + # ------------------------------------------------------------------ + + def style_transfer_submit( + self, + content_image_b64: str, + style_image_b64: str, + strength: Optional[float] = None, + ) -> str: + payload: dict[str, Any] = { + "content_image": content_image_b64, + "style_image": style_image_b64, + } + if strength is not None: + payload["strength"] = strength + + raw = self._client.post("/v1/ai/image-style-transfer", json=payload) + return get_task_id(raw) + + def style_transfer_status(self, task_id: str) -> Tuple[str, dict[str, Any]]: + raw = self._client.get(f"/v1/ai/image-style-transfer/{task_id}") + return get_status(raw), raw + + def get_output_urls(self, raw: dict[str, Any]) -> list[str]: + return get_output_urls(raw) diff --git a/freepik_cli/api/images.py b/freepik_cli/api/images.py new file mode 100644 index 0000000..7bf213a --- /dev/null +++ b/freepik_cli/api/images.py @@ -0,0 +1,105 @@ +"""Image generation and analysis API methods.""" + +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from freepik_cli.api.client import FreepikClient +from freepik_cli.api.models import ( + IMAGE_POST_ENDPOINTS, + IMAGE_STATUS_ENDPOINTS, + ImageModel, + get_output_urls, + get_status, + get_task_id, +) + + +class ImageAPI: + def __init__(self, client: FreepikClient) -> None: + self._client = client + + def generate(self, model: ImageModel, payload: dict[str, Any]) -> str: + """Submit a generation task. Returns task_id.""" + endpoint = IMAGE_POST_ENDPOINTS[model] + raw = self._client.post(endpoint, json=payload) + return get_task_id(raw) + + def get_status(self, model: ImageModel, task_id: str) -> Tuple[str, dict[str, Any]]: + """Poll status. Returns (status_str, raw_response).""" + endpoint = IMAGE_STATUS_ENDPOINTS[model].format(task_id=task_id) + raw = self._client.get(endpoint) + return get_status(raw), raw + + def get_output_urls(self, raw: dict[str, Any]) -> list[str]: + return get_output_urls(raw) + + # ------------------------------------------------------------------ + # Image-to-prompt (describe) + # ------------------------------------------------------------------ + + def describe_submit(self, image_b64: str) -> str: + """Submit image-to-prompt task. Returns task_id.""" + raw = self._client.post("/v1/ai/image-to-prompt", json={"image": image_b64}) + return get_task_id(raw) + + def describe_status(self, task_id: str) -> Tuple[str, dict[str, Any]]: + raw = self._client.get(f"/v1/ai/image-to-prompt/{task_id}") + return get_status(raw), raw + + def get_prompt_text(self, raw: dict[str, Any]) -> str: + """Extract generated prompt text from a completed describe response.""" + data = raw.get("data", raw) + return ( + data.get("prompt") + or data.get("description") + or data.get("text") + or data.get("result", {}).get("prompt", "") + or "" + ) + + # ------------------------------------------------------------------ + # Image expansion (outpainting) + # ------------------------------------------------------------------ + + def expand_submit( + self, + model: str, + image_b64: str, + left: int = 0, + right: int = 0, + top: int = 0, + bottom: int = 0, + prompt: Optional[str] = None, + seed: Optional[int] = None, + ) -> str: + payload: dict[str, Any] = { + "image": image_b64, + "left": left, + "right": right, + "top": top, + "bottom": bottom, + } + if prompt: + payload["prompt"] = prompt + if seed is not None: + payload["seed"] = seed + + endpoint_map = { + "flux-pro": "/v1/ai/image-expand/flux-pro", + "ideogram": "/v1/ai/image-expand/ideogram", + "seedream-v4-5": "/v1/ai/image-expand/seedream-v4-5", + } + endpoint = endpoint_map.get(model, "/v1/ai/image-expand/flux-pro") + raw = self._client.post(endpoint, json=payload) + return get_task_id(raw) + + def expand_status(self, model: str, task_id: str) -> Tuple[str, dict[str, Any]]: + endpoint_map = { + "flux-pro": "/v1/ai/image-expand/flux-pro", + "ideogram": "/v1/ai/image-expand/ideogram", + "seedream-v4-5": "/v1/ai/image-expand/seedream-v4-5", + } + base = endpoint_map.get(model, "/v1/ai/image-expand/flux-pro") + raw = self._client.get(f"{base}/{task_id}") + return get_status(raw), raw diff --git a/freepik_cli/api/models.py b/freepik_cli/api/models.py new file mode 100644 index 0000000..d023524 --- /dev/null +++ b/freepik_cli/api/models.py @@ -0,0 +1,178 @@ +"""Enums, endpoint maps, and response normalization utilities.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + + +# --------------------------------------------------------------------------- +# Model enums +# --------------------------------------------------------------------------- + +class ImageModel(str, Enum): + MYSTIC = "mystic" + FLUX_KONTEXT_PRO = "flux-kontext-pro" + FLUX_2_PRO = "flux-2-pro" + FLUX_2_TURBO = "flux-2-turbo" + FLUX_PRO_1_1 = "flux-pro-1.1" + SEEDREAM_V4 = "seedream-v4" + SEEDREAM_V4_5 = "seedream-v4-5" + IDEOGRAM_V2 = "ideogram-v2" + + +class VideoModel(str, Enum): + KLING_O1_PRO = "kling-o1-pro" + KLING_O1_STD = "kling-o1-std" + KLING_ELEMENTS_PRO = "kling-elements-pro" + KLING_ELEMENTS_STD = "kling-elements-std" + MINIMAX_HAILUO = "minimax-hailuo" + WAN_2_5 = "wan-2.5" + RUNWAY_GEN4 = "runway-gen4" + + +class UpscaleMode(str, Enum): + CREATIVE = "creative" + PRECISION = "precision" + PRECISION_V2 = "precision-v2" + + +class VideoUpscaleMode(str, Enum): + STANDARD = "standard" + TURBO = "turbo" + + +class AspectRatio(str, Enum): + LANDSCAPE = "16:9" + PORTRAIT = "9:16" + SQUARE = "1:1" + CLASSIC = "4:3" + WIDE = "21:9" + + +class IconStyle(str, Enum): + SOLID = "solid" + OUTLINE = "outline" + COLOR = "color" + FLAT = "flat" + STICKER = "sticker" + + +# --------------------------------------------------------------------------- +# Endpoint maps +# --------------------------------------------------------------------------- + +IMAGE_POST_ENDPOINTS: dict[ImageModel, str] = { + ImageModel.MYSTIC: "/v1/ai/mystic", + ImageModel.FLUX_KONTEXT_PRO: "/v1/ai/text-to-image/flux-kontext-pro", + ImageModel.FLUX_2_PRO: "/v1/ai/text-to-image/flux-2-pro", + ImageModel.FLUX_2_TURBO: "/v1/ai/text-to-image/flux-2-turbo", + ImageModel.FLUX_PRO_1_1: "/v1/ai/text-to-image/flux-pro-v1-1", + ImageModel.SEEDREAM_V4: "/v1/ai/text-to-image/seedream-v4", + ImageModel.SEEDREAM_V4_5: "/v1/ai/text-to-image/seedream-v4-5", + ImageModel.IDEOGRAM_V2: "/v1/ai/text-to-image/ideogram-v2", +} + +IMAGE_STATUS_ENDPOINTS: dict[ImageModel, str] = { + ImageModel.MYSTIC: "/v1/ai/mystic/{task_id}", + ImageModel.FLUX_KONTEXT_PRO: "/v1/ai/text-to-image/flux-kontext-pro/{task_id}", + ImageModel.FLUX_2_PRO: "/v1/ai/text-to-image/flux-2-pro/{task_id}", + ImageModel.FLUX_2_TURBO: "/v1/ai/text-to-image/flux-2-turbo/{task_id}", + ImageModel.FLUX_PRO_1_1: "/v1/ai/text-to-image/flux-pro-v1-1/{task_id}", + ImageModel.SEEDREAM_V4: "/v1/ai/text-to-image/seedream-v4/{task_id}", + ImageModel.SEEDREAM_V4_5: "/v1/ai/text-to-image/seedream-v4-5/{task_id}", + ImageModel.IDEOGRAM_V2: "/v1/ai/text-to-image/ideogram-v2/{task_id}", +} + +VIDEO_POST_ENDPOINTS: dict[VideoModel, str] = { + VideoModel.KLING_O1_PRO: "/v1/ai/image-to-video/kling-o1-pro", + VideoModel.KLING_O1_STD: "/v1/ai/image-to-video/kling-o1-std", + VideoModel.KLING_ELEMENTS_PRO: "/v1/ai/image-to-video/kling-elements-pro", + VideoModel.KLING_ELEMENTS_STD: "/v1/ai/image-to-video/kling-elements-std", + VideoModel.MINIMAX_HAILUO: "/v1/ai/image-to-video/minimax-hailuo-02-1080p", + VideoModel.WAN_2_5: "/v1/ai/image-to-video/wan-2-5", + VideoModel.RUNWAY_GEN4: "/v1/ai/image-to-video/runway-gen4", +} + +VIDEO_STATUS_ENDPOINTS: dict[VideoModel, str] = { + VideoModel.KLING_O1_PRO: "/v1/ai/image-to-video/kling-o1/{task_id}", + VideoModel.KLING_O1_STD: "/v1/ai/image-to-video/kling-o1/{task_id}", + VideoModel.KLING_ELEMENTS_PRO: "/v1/ai/image-to-video/kling-elements-pro/{task_id}", + VideoModel.KLING_ELEMENTS_STD: "/v1/ai/image-to-video/kling-elements-std/{task_id}", + VideoModel.MINIMAX_HAILUO: "/v1/ai/image-to-video/minimax-hailuo-02-1080p/{task_id}", + VideoModel.WAN_2_5: "/v1/ai/image-to-video/wan-2-5/{task_id}", + VideoModel.RUNWAY_GEN4: "/v1/ai/image-to-video/runway-gen4/{task_id}", +} + +UPSCALE_POST_ENDPOINTS: dict[UpscaleMode, str] = { + UpscaleMode.CREATIVE: "/v1/ai/image-upscaler", + UpscaleMode.PRECISION: "/v1/ai/image-upscaler-precision", + UpscaleMode.PRECISION_V2: "/v1/ai/image-upscaler-precision-v2", +} + +UPSCALE_STATUS_ENDPOINTS: dict[UpscaleMode, str] = { + UpscaleMode.CREATIVE: "/v1/ai/image-upscaler/{task_id}", + UpscaleMode.PRECISION: "/v1/ai/image-upscaler-precision/{task_id}", + UpscaleMode.PRECISION_V2: "/v1/ai/image-upscaler-precision-v2/{task_id}", +} + +VIDEO_UPSCALE_POST_ENDPOINTS: dict[VideoUpscaleMode, str] = { + VideoUpscaleMode.STANDARD: "/v1/ai/video-upscaler", + VideoUpscaleMode.TURBO: "/v1/ai/video-upscaler/turbo", +} + +VIDEO_UPSCALE_STATUS_ENDPOINT = "/v1/ai/video-upscaler/{task_id}" + + +# --------------------------------------------------------------------------- +# Response normalization helpers +# --------------------------------------------------------------------------- + +def get_task_id(raw: dict[str, Any]) -> str: + """Extract task_id from any response shape.""" + data = raw.get("data", raw) + task_id = data.get("task_id") or data.get("id") or raw.get("task_id") or raw.get("id") + if not task_id: + raise ValueError(f"No task_id found in response: {raw}") + return str(task_id) + + +def get_status(raw: dict[str, Any]) -> str: + """Extract normalized status string from any response shape.""" + data = raw.get("data", raw) + status = data.get("status") or raw.get("status") or "PENDING" + return status.upper().replace(" ", "_") + + +def get_output_urls(raw: dict[str, Any]) -> list[str]: + """Extract all output file URLs from a completed task response.""" + data = raw.get("data", raw) + + # Try common key names in order of likelihood + for key in ("generated", "output", "outputs", "result", "results", "images", "videos"): + items = data.get(key) + if items is None: + continue + if isinstance(items, list) and items: + urls = [] + for item in items: + if isinstance(item, dict): + url = item.get("url") or item.get("download_url") or item.get("src") + if url: + urls.append(url) + elif isinstance(item, str): + urls.append(item) + if urls: + return urls + elif isinstance(items, dict): + url = items.get("url") or items.get("download_url") or items.get("src") + if url: + return [url] + elif isinstance(items, str): + return [items] + + # Fallback: top-level url field + if "url" in data: + return [data["url"]] + + return [] diff --git a/freepik_cli/api/upscale.py b/freepik_cli/api/upscale.py new file mode 100644 index 0000000..3202f5d --- /dev/null +++ b/freepik_cli/api/upscale.py @@ -0,0 +1,88 @@ +"""Image and video upscaling API methods.""" + +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from freepik_cli.api.client import FreepikClient +from freepik_cli.api.models import ( + UPSCALE_POST_ENDPOINTS, + UPSCALE_STATUS_ENDPOINTS, + VIDEO_UPSCALE_POST_ENDPOINTS, + VIDEO_UPSCALE_STATUS_ENDPOINT, + UpscaleMode, + VideoUpscaleMode, + get_output_urls, + get_status, + get_task_id, +) + + +class UpscaleAPI: + def __init__(self, client: FreepikClient) -> None: + self._client = client + + # ------------------------------------------------------------------ + # Image upscaling + # ------------------------------------------------------------------ + + def upscale_image( + self, + mode: UpscaleMode, + image_b64: str, + scale_factor: Optional[str] = None, + creativity: Optional[int] = None, + prompt: Optional[str] = None, + seed: Optional[int] = None, + ) -> str: + """Submit an image upscale task. Returns task_id.""" + payload: dict[str, Any] = {"image": image_b64} + + if scale_factor: + # Convert "2x" → 2, "4x" → 4 + factor = scale_factor.rstrip("xX") + try: + payload["scale_factor"] = int(factor) + except ValueError: + payload["scale_factor"] = 2 + + if mode == UpscaleMode.CREATIVE: + if creativity is not None: + payload["creativity"] = creativity + if prompt: + payload["prompt"] = prompt + + if seed is not None: + payload["seed"] = seed + + endpoint = UPSCALE_POST_ENDPOINTS[mode] + raw = self._client.post(endpoint, json=payload) + return get_task_id(raw) + + def upscale_image_status(self, mode: UpscaleMode, task_id: str) -> Tuple[str, dict[str, Any]]: + endpoint = UPSCALE_STATUS_ENDPOINTS[mode].format(task_id=task_id) + raw = self._client.get(endpoint) + return get_status(raw), raw + + # ------------------------------------------------------------------ + # Video upscaling + # ------------------------------------------------------------------ + + def upscale_video( + self, + mode: VideoUpscaleMode, + video_b64: str, + ) -> str: + """Submit a video upscale task. Returns task_id.""" + payload: dict[str, Any] = {"video": video_b64} + endpoint = VIDEO_UPSCALE_POST_ENDPOINTS[mode] + raw = self._client.post(endpoint, json=payload) + return get_task_id(raw) + + def upscale_video_status(self, task_id: str) -> Tuple[str, dict[str, Any]]: + endpoint = VIDEO_UPSCALE_STATUS_ENDPOINT.format(task_id=task_id) + raw = self._client.get(endpoint) + return get_status(raw), raw + + def get_output_urls(self, raw: dict[str, Any]) -> list[str]: + return get_output_urls(raw) diff --git a/freepik_cli/api/videos.py b/freepik_cli/api/videos.py new file mode 100644 index 0000000..e50bed9 --- /dev/null +++ b/freepik_cli/api/videos.py @@ -0,0 +1,55 @@ +"""Video generation API methods.""" + +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from freepik_cli.api.client import FreepikClient +from freepik_cli.api.models import ( + VIDEO_POST_ENDPOINTS, + VIDEO_STATUS_ENDPOINTS, + VideoModel, + get_output_urls, + get_status, + get_task_id, +) + + +class VideoAPI: + def __init__(self, client: FreepikClient) -> None: + self._client = client + + def generate( + self, + model: VideoModel, + image_b64: str, + prompt: Optional[str] = None, + duration: int = 5, + aspect_ratio: str = "16:9", + seed: Optional[int] = None, + ) -> str: + """Submit an image-to-video task. Returns task_id.""" + payload: dict[str, Any] = { + "image": image_b64, + } + if prompt: + payload["prompt"] = prompt + if duration: + payload["duration"] = duration + if aspect_ratio: + payload["aspect_ratio"] = aspect_ratio + if seed is not None: + payload["seed"] = seed + + endpoint = VIDEO_POST_ENDPOINTS[model] + raw = self._client.post(endpoint, json=payload) + return get_task_id(raw) + + def get_status(self, model: VideoModel, task_id: str) -> Tuple[str, dict[str, Any]]: + """Poll status. Returns (status_str, raw_response).""" + endpoint = VIDEO_STATUS_ENDPOINTS[model].format(task_id=task_id) + raw = self._client.get(endpoint) + return get_status(raw), raw + + def get_output_urls(self, raw: dict[str, Any]) -> list[str]: + return get_output_urls(raw) diff --git a/freepik_cli/commands/__init__.py b/freepik_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freepik_cli/commands/analyze.py b/freepik_cli/commands/analyze.py new file mode 100644 index 0000000..bb58516 --- /dev/null +++ b/freepik_cli/commands/analyze.py @@ -0,0 +1,97 @@ +"""describe-image command.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from freepik_cli.api.client import FreepikAPIError, FreepikClient +from freepik_cli.api.images import ImageAPI +from freepik_cli.utils.config import FreepikConfig +from freepik_cli.utils.console import console, print_describe_result, print_error, print_no_wait +from freepik_cli.utils.files import image_to_base64 +from freepik_cli.utils.polling import FreepikTaskError, FreepikTimeoutError, PollConfig, poll_task + + +def _get_api_key(api_key: Optional[str], config: FreepikConfig) -> str: + key = api_key or config.api_key + if not key: + print_error("No API key found.", hint="Set [cyan]FREEPIK_API_KEY[/cyan] or pass [cyan]--api-key[/cyan].") + raise typer.Exit(1) + return key + + +def describe_image( + image: Annotated[ + Path, + typer.Argument( + help="Image to analyze and describe", + exists=True, file_okay=True, dir_okay=False, + ), + ], + output: Annotated[ + Optional[Path], + typer.Option("--output", "-o", help="Save the generated prompt to a text file"), + ] = None, + wait: Annotated[ + bool, + typer.Option("--wait/--no-wait"), + ] = True, + api_key: Annotated[ + Optional[str], + typer.Option("--api-key", envvar="FREEPIK_API_KEY"), + ] = None, +) -> None: + """ + [bold]Describe an image[/bold] and generate a text prompt for it. + + Reverse-engineers the image into an AI-ready prompt you can use with + [cyan]generate-image[/cyan]. + + [dim]Examples:[/dim] + freepik describe-image photo.jpg + freepik describe-image scene.png --output prompt.txt + """ + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + image_b64 = image_to_base64(image) + + with FreepikClient(key, base_url=config.base_url) as client: + api = ImageAPI(client) + + with console.status("[info]Submitting image analysis…[/info]"): + try: + task_id = api.describe_submit(image_b64) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "describe-image", "image-to-prompt") + return + + poll_config = PollConfig(task_type="describe", max_wait=config.poll_timeout) + try: + result = poll_task( + check_fn=lambda tid: api.describe_status(tid), + task_id=task_id, + config=poll_config, + console=console, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + prompt_text = api.get_prompt_text(result) + if not prompt_text: + print_error("Analysis completed but no prompt text found.") + raise typer.Exit(1) + + saved_path: Optional[Path] = None + if output: + output.write_text(prompt_text, encoding="utf-8") + saved_path = output + + print_describe_result(task_id, prompt_text, saved_path) diff --git a/freepik_cli/commands/config.py b/freepik_cli/commands/config.py new file mode 100644 index 0000000..32e73bc --- /dev/null +++ b/freepik_cli/commands/config.py @@ -0,0 +1,120 @@ +"""config set/get/show/reset commands.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer +from rich.prompt import Confirm + +from freepik_cli.utils.config import CONFIG_FILE, FreepikConfig +from freepik_cli.utils.console import console, print_config_table, print_config_toml, print_error, print_warning + +app = typer.Typer( + name="config", + help="[bold]Manage[/bold] Freepik CLI configuration.", + rich_markup_mode="rich", + no_args_is_help=True, +) + + +@app.command("show") +def config_show( + toml: Annotated[ + bool, + typer.Option("--toml", help="Output as TOML syntax instead of a table"), + ] = False, +) -> None: + """[bold]Show[/bold] all current configuration values.""" + config = FreepikConfig.load() + d = config.to_display_dict() + if toml: + print_config_toml(d) + else: + print_config_table(d) + console.print(f"\n[dim]Config file:[/dim] {CONFIG_FILE}") + + +@app.command("get") +def config_get( + key: Annotated[str, typer.Argument(help="Config key to retrieve")], +) -> None: + """[bold]Get[/bold] the value of a single configuration key.""" + config = FreepikConfig.load() + d = config.to_display_dict() + if key not in d: + print_error( + f"Unknown config key: '{key}'", + hint=f"Run [cyan]freepik config show[/cyan] to see all available keys.", + ) + raise typer.Exit(1) + + value = d[key] + if key == "api_key" and value: + masked = f"{'*' * 8}{str(value)[-4:]}" + console.print(f"[dim]{key}[/dim] = [bold]{masked}[/bold] [dim](masked)[/dim]") + elif value is None: + console.print(f"[dim]{key}[/dim] = [dim]not set[/dim]") + else: + console.print(f"[dim]{key}[/dim] = [bold]{value}[/bold]") + + +@app.command("set") +def config_set( + key: Annotated[str, typer.Argument(help="Config key to update")], + value: Annotated[str, typer.Argument(help="New value")], +) -> None: + """ + [bold]Set[/bold] a configuration value. + + [dim]Examples:[/dim] + freepik config set default_image_model mystic + freepik config set default_output_dir ~/images + freepik config set poll_timeout 300 + + [dim]Note:[/dim] The API key is never saved to disk. Use the + [cyan]FREEPIK_API_KEY[/cyan] environment variable instead. + """ + config = FreepikConfig.load() + try: + config.set_value(key, value) + console.print(f"[success]✓[/success] Set [cyan]{key}[/cyan] = [bold]{value}[/bold]") + console.print(f"[dim]Saved to:[/dim] {CONFIG_FILE}") + except ValueError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + +@app.command("reset") +def config_reset( + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip confirmation prompt"), + ] = False, +) -> None: + """[bold]Reset[/bold] all configuration to defaults.""" + if not yes: + confirmed = Confirm.ask( + "[warning]Reset all configuration to defaults?[/warning]", + console=console, + default=False, + ) + if not confirmed: + console.print("[dim]Aborted.[/dim]") + return + + if CONFIG_FILE.exists(): + CONFIG_FILE.unlink() + console.print(f"[success]✓[/success] Configuration reset. Deleted: [path]{CONFIG_FILE}[/path]") + else: + print_warning("No config file found — already at defaults.") + + +@app.command("path") +def config_path() -> None: + """Show the path to the configuration file.""" + console.print(f"[path]{CONFIG_FILE}[/path]") + if CONFIG_FILE.exists(): + console.print("[dim](file exists)[/dim]") + else: + console.print("[dim](file does not exist — using defaults)[/dim]") diff --git a/freepik_cli/commands/edit.py b/freepik_cli/commands/edit.py new file mode 100644 index 0000000..7a06c70 --- /dev/null +++ b/freepik_cli/commands/edit.py @@ -0,0 +1,215 @@ +"""expand-image, relight, style-transfer commands.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from freepik_cli.api.client import FreepikAPIError, FreepikClient +from freepik_cli.api.edit import EditAPI +from freepik_cli.utils.config import FreepikConfig +from freepik_cli.utils.console import GenerationResult, console, print_error, print_no_wait, print_result +from freepik_cli.utils.files import auto_output_path, get_image_dimensions, image_to_base64, save_from_url +from freepik_cli.utils.polling import FreepikTaskError, FreepikTimeoutError, PollConfig, poll_task + +_EXPAND_MODELS = ["flux-pro", "ideogram", "seedream-v4-5"] + + +def _get_api_key(api_key: Optional[str], config: FreepikConfig) -> str: + key = api_key or config.api_key + if not key: + print_error("No API key found.", hint="Set [cyan]FREEPIK_API_KEY[/cyan] or pass [cyan]--api-key[/cyan].") + raise typer.Exit(1) + return key + + +def expand_image( + image: Annotated[ + Path, + typer.Argument(help="Image to expand (outpaint)", exists=True), + ], + left: Annotated[int, typer.Option("--left", help="Pixels to add on the left (0–2048)", min=0, max=2048)] = 0, + right: Annotated[int, typer.Option("--right", help="Pixels to add on the right (0–2048)", min=0, max=2048)] = 0, + top: Annotated[int, typer.Option("--top", help="Pixels to add on top (0–2048)", min=0, max=2048)] = 0, + bottom: Annotated[int, typer.Option("--bottom", help="Pixels to add on bottom (0–2048)", min=0, max=2048)] = 0, + prompt: Annotated[Optional[str], typer.Option("--prompt", "-p", help="Optional prompt to guide the expansion")] = None, + model: Annotated[str, typer.Option("--model", "-m", help=f"Expansion model: {', '.join(_EXPAND_MODELS)}")] = "flux-pro", + seed: Annotated[Optional[int], typer.Option("--seed")] = None, + output: Annotated[Optional[Path], typer.Option("--output", "-o")] = None, + wait: Annotated[bool, typer.Option("--wait/--no-wait")] = True, + api_key: Annotated[Optional[str], typer.Option("--api-key", envvar="FREEPIK_API_KEY")] = None, +) -> None: + """ + [bold]Expand an image[/bold] by adding new content around its edges (outpainting). + + [dim]Examples:[/dim] + freepik expand-image photo.jpg --left 512 --right 512 --prompt "lush forest" + freepik expand-image banner.png --bottom 256 --model seedream-v4-5 + """ + if not any([left, right, top, bottom]): + print_error("At least one of --left, --right, --top, --bottom must be > 0.") + raise typer.Exit(1) + + if model not in _EXPAND_MODELS: + print_error(f"Unknown model '{model}'.", hint=f"Choose from: {', '.join(_EXPAND_MODELS)}") + raise typer.Exit(1) + + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + image_b64 = image_to_base64(image) + + with FreepikClient(key, base_url=config.base_url) as client: + api = EditAPI(client) + + with console.status("[info]Submitting image expansion…[/info]"): + try: + task_id = api.expand_submit(model, image_b64, left, right, top, bottom, prompt, seed) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "expand-image", model) + return + + poll_config = PollConfig(task_type="expand", max_wait=config.poll_timeout) + from freepik_cli.api.images import ImageAPI + img_api = ImageAPI(client) + try: + result = poll_task( + check_fn=lambda tid: img_api.expand_status(model, tid), + task_id=task_id, + config=poll_config, + console=console, + extra_info={"Model": model, "Expand": f"L{left} R{right} T{top} B{bottom}"}, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + from freepik_cli.api.models import get_output_urls + urls = get_output_urls(result) + if not urls: + print_error("Expansion completed but no output URL found.") + raise typer.Exit(1) + + out = output or auto_output_path("expanded", model, image.suffix.lstrip(".") or "jpg", config.default_output_dir) + save_from_url(urls[0], out, console) + w, h = get_image_dimensions(out) + print_result(GenerationResult(task_id=task_id, model=model, output_path=out, width=w, height=h, task_type="expand")) + + +def relight_image( + image: Annotated[Path, typer.Argument(help="Image to relight", exists=True)], + prompt: Annotated[Optional[str], typer.Option("--prompt", "-p", help="Lighting description e.g. 'golden hour sunlight'")] = None, + style: Annotated[Optional[str], typer.Option("--style", "-s", help="Lighting style preset")] = None, + output: Annotated[Optional[Path], typer.Option("--output", "-o")] = None, + wait: Annotated[bool, typer.Option("--wait/--no-wait")] = True, + api_key: Annotated[Optional[str], typer.Option("--api-key", envvar="FREEPIK_API_KEY")] = None, +) -> None: + """ + [bold]Relight an image[/bold] using AI-controlled lighting. [dim](Premium feature)[/dim] + + [dim]Examples:[/dim] + freepik relight portrait.jpg --prompt "dramatic studio lighting" + freepik relight scene.png --prompt "warm golden hour" --output relit.jpg + """ + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + image_b64 = image_to_base64(image) + + with FreepikClient(key, base_url=config.base_url) as client: + api = EditAPI(client) + + with console.status("[info]Submitting image relight…[/info]"): + try: + task_id = api.relight_submit(image_b64, prompt, style) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "relight", "image-relight") + return + + poll_config = PollConfig(task_type="relight", max_wait=config.poll_timeout) + try: + result = poll_task( + check_fn=lambda tid: api.relight_status(tid), + task_id=task_id, + config=poll_config, + console=console, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + urls = api.get_output_urls(result) + if not urls: + print_error("Relight completed but no output URL found.") + raise typer.Exit(1) + + out = output or auto_output_path("relit", "relight", image.suffix.lstrip(".") or "jpg", config.default_output_dir) + save_from_url(urls[0], out, console) + w, h = get_image_dimensions(out) + print_result(GenerationResult(task_id=task_id, model="image-relight", output_path=out, width=w, height=h, task_type="relight")) + + +def style_transfer( + content: Annotated[Path, typer.Argument(help="Content image (what to keep)", exists=True)], + style_image: Annotated[Path, typer.Argument(help="Style image (the artistic style to apply)", exists=True)], + strength: Annotated[Optional[float], typer.Option("--strength", help="Style strength 0.0–1.0", min=0.0, max=1.0)] = None, + output: Annotated[Optional[Path], typer.Option("--output", "-o")] = None, + wait: Annotated[bool, typer.Option("--wait/--no-wait")] = True, + api_key: Annotated[Optional[str], typer.Option("--api-key", envvar="FREEPIK_API_KEY")] = None, +) -> None: + """ + [bold]Apply an artistic style[/bold] from one image onto another. [dim](Premium feature)[/dim] + + [dim]Examples:[/dim] + freepik style-transfer photo.jpg painting.jpg --strength 0.8 + freepik style-transfer portrait.png van_gogh.jpg --output styled.jpg + """ + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + + content_b64 = image_to_base64(content) + style_b64 = image_to_base64(style_image) + + with FreepikClient(key, base_url=config.base_url) as client: + api = EditAPI(client) + + with console.status("[info]Submitting style transfer…[/info]"): + try: + task_id = api.style_transfer_submit(content_b64, style_b64, strength) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "style-transfer", "image-style-transfer") + return + + poll_config = PollConfig(task_type="style-transfer", max_wait=config.poll_timeout) + try: + result = poll_task( + check_fn=lambda tid: api.style_transfer_status(tid), + task_id=task_id, + config=poll_config, + console=console, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + urls = api.get_output_urls(result) + if not urls: + print_error("Style transfer completed but no output URL found.") + raise typer.Exit(1) + + out = output or auto_output_path("styled", "style-transfer", "jpg", config.default_output_dir) + save_from_url(urls[0], out, console) + w, h = get_image_dimensions(out) + print_result(GenerationResult(task_id=task_id, model="image-style-transfer", output_path=out, width=w, height=h, task_type="style-transfer")) diff --git a/freepik_cli/commands/generate.py b/freepik_cli/commands/generate.py new file mode 100644 index 0000000..67e89a1 --- /dev/null +++ b/freepik_cli/commands/generate.py @@ -0,0 +1,354 @@ +"""generate-image, generate-video, generate-icon commands.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from freepik_cli.api.client import FreepikAPIError, FreepikClient +from freepik_cli.api.edit import EditAPI +from freepik_cli.api.images import ImageAPI +from freepik_cli.api.models import IconStyle, ImageModel, VideoModel +from freepik_cli.api.videos import VideoAPI +from freepik_cli.utils.config import FreepikConfig +from freepik_cli.utils.console import GenerationResult, console, print_error, print_no_wait, print_result +from freepik_cli.utils.files import auto_output_path, get_image_dimensions, image_to_base64, save_from_url +from freepik_cli.utils.polling import FreepikTaskError, FreepikTimeoutError, PollConfig, poll_task + + +def _get_api_key(api_key: Optional[str], config: FreepikConfig) -> str: + key = api_key or config.api_key + if not key: + print_error( + "No API key found.", + hint="Set the [cyan]FREEPIK_API_KEY[/cyan] environment variable, " + "or pass [cyan]--api-key YOUR_KEY[/cyan].", + ) + raise typer.Exit(1) + return key + + +def generate_image( + prompt: Annotated[str, typer.Argument(help="Text prompt describing the image to generate")], + model: Annotated[ + ImageModel, + typer.Option("--model", "-m", help="AI model to use for generation", show_default=True), + ] = ImageModel.FLUX_2_PRO, + output: Annotated[ + Optional[Path], + typer.Option("--output", "-o", help="Output file path (auto-generated if omitted)"), + ] = None, + aspect_ratio: Annotated[ + Optional[str], + typer.Option( + "--aspect-ratio", "-a", + help="Aspect ratio e.g. [cyan]16:9[/cyan], [cyan]1:1[/cyan], [cyan]9:16[/cyan], [cyan]4:3[/cyan]", + ), + ] = None, + negative_prompt: Annotated[ + Optional[str], + typer.Option("--negative-prompt", "-n", help="Concepts to exclude from the image"), + ] = None, + seed: Annotated[ + Optional[int], + typer.Option("--seed", help="Random seed for reproducibility"), + ] = None, + input_image: Annotated[ + Optional[Path], + typer.Option( + "--input-image", "-i", + help="Reference image for img2img / editing (flux-kontext-pro)", + exists=True, file_okay=True, dir_okay=False, + ), + ] = None, + wait: Annotated[ + bool, + typer.Option("--wait/--no-wait", help="Wait for completion or return task ID immediately"), + ] = True, + api_key: Annotated[ + Optional[str], + typer.Option("--api-key", envvar="FREEPIK_API_KEY", help="Freepik API key"), + ] = None, +) -> None: + """ + [bold]Generate an image[/bold] using Freepik AI models. + + [dim]Examples:[/dim] + freepik generate-image "a cat on the moon" --model flux-2-pro + freepik generate-image "cyberpunk city at night" --model mystic --aspect-ratio 16:9 + freepik generate-image "make the sky orange" --model flux-kontext-pro --input-image photo.jpg + """ + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + + # Build request payload + payload: dict = {"prompt": prompt} + if aspect_ratio: + payload["aspect_ratio"] = aspect_ratio + if negative_prompt: + payload["negative_prompt"] = negative_prompt + if seed is not None: + payload["seed"] = seed + if input_image: + payload["image"] = image_to_base64(input_image) + + with FreepikClient(key, base_url=config.base_url) as client: + api = ImageAPI(client) + + with console.status(f"[info]Submitting {model.value} generation…[/info]"): + try: + task_id = api.generate(model, payload) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "image", model.value) + return + + poll_config = PollConfig(task_type="image", max_wait=config.poll_timeout) + try: + result = poll_task( + check_fn=lambda tid: api.get_status(model, tid), + task_id=task_id, + config=poll_config, + console=console, + extra_info={"Model": f"[magenta]{model.value}[/magenta]"}, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + urls = api.get_output_urls(result) + if not urls: + print_error("Generation completed but no output URLs found.", hint="Check the Freepik dashboard.") + raise typer.Exit(1) + + out = output or auto_output_path("image", model.value, "jpg", config.default_output_dir) + save_from_url(urls[0], out, console) + + w, h = get_image_dimensions(out) + print_result( + GenerationResult( + task_id=task_id, + model=model.value, + output_path=out, + width=w, + height=h, + seed=seed, + task_type="image", + ) + ) + + +def generate_video( + image: Annotated[ + Path, + typer.Argument( + help="Source image path to animate", + exists=True, file_okay=True, dir_okay=False, + ), + ], + model: Annotated[ + VideoModel, + typer.Option("--model", "-m", help="Video AI model", show_default=True), + ] = VideoModel.KLING_O1_PRO, + prompt: Annotated[ + Optional[str], + typer.Option("--prompt", "-p", help="Motion/style guidance prompt"), + ] = None, + duration: Annotated[ + int, + typer.Option("--duration", "-d", help="Video duration in seconds: [cyan]5[/cyan] or [cyan]10[/cyan]", min=5, max=10), + ] = 5, + aspect_ratio: Annotated[ + str, + typer.Option("--aspect-ratio", "-a", help="Output aspect ratio: [cyan]16:9[/cyan] | [cyan]9:16[/cyan] | [cyan]1:1[/cyan]"), + ] = "16:9", + seed: Annotated[ + Optional[int], + typer.Option("--seed", help="Random seed for reproducibility"), + ] = None, + output: Annotated[ + Optional[Path], + typer.Option("--output", "-o", help="Output video file path"), + ] = None, + wait: Annotated[ + bool, + typer.Option("--wait/--no-wait", help="Wait for completion or return task ID immediately"), + ] = True, + api_key: Annotated[ + Optional[str], + typer.Option("--api-key", envvar="FREEPIK_API_KEY", help="Freepik API key"), + ] = None, +) -> None: + """ + [bold]Generate a video[/bold] from a source image using AI. + + [dim]Examples:[/dim] + freepik generate-video photo.jpg --prompt "gentle ocean waves" --model kling-o1-pro + freepik generate-video portrait.png --model minimax-hailuo --duration 10 --aspect-ratio 9:16 + """ + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + + image_b64 = image_to_base64(image) + + with FreepikClient(key, base_url=config.base_url) as client: + api = VideoAPI(client) + + with console.status(f"[info]Submitting {model.value} video generation…[/info]"): + try: + task_id = api.generate( + model=model, + image_b64=image_b64, + prompt=prompt, + duration=duration, + aspect_ratio=aspect_ratio, + seed=seed, + ) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "video", model.value) + return + + poll_config = PollConfig( + task_type="video", + initial_delay=5.0, + max_wait=config.poll_timeout, + ) + try: + result = poll_task( + check_fn=lambda tid: api.get_status(model, tid), + task_id=task_id, + config=poll_config, + console=console, + extra_info={ + "Model": f"[magenta]{model.value}[/magenta]", + "Duration": f"{duration}s", + "Ratio": aspect_ratio, + }, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + urls = api.get_output_urls(result) + if not urls: + print_error("Generation completed but no output URLs found.") + raise typer.Exit(1) + + out = output or auto_output_path("video", model.value, "mp4", config.default_output_dir) + save_from_url(urls[0], out, console) + + print_result( + GenerationResult( + task_id=task_id, + model=model.value, + output_path=out, + duration=str(duration), + task_type="video", + ) + ) + + +def generate_icon( + prompt: Annotated[str, typer.Argument(help="Text prompt for the icon")], + style: Annotated[ + IconStyle, + typer.Option("--style", "-s", help="Icon style", show_default=True), + ] = IconStyle.COLOR, + steps: Annotated[ + int, + typer.Option("--steps", help="Inference steps (10–50)", min=10, max=50), + ] = 30, + guidance: Annotated[ + float, + typer.Option("--guidance", help="Guidance scale (0–10)", min=0.0, max=10.0), + ] = 7.5, + seed: Annotated[ + Optional[int], + typer.Option("--seed", help="Random seed"), + ] = None, + fmt: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: [cyan]png[/cyan] | [cyan]svg[/cyan]"), + ] = "png", + output: Annotated[ + Optional[Path], + typer.Option("--output", "-o", help="Output file path"), + ] = None, + wait: Annotated[ + bool, + typer.Option("--wait/--no-wait"), + ] = True, + api_key: Annotated[ + Optional[str], + typer.Option("--api-key", envvar="FREEPIK_API_KEY"), + ] = None, +) -> None: + """ + [bold]Generate an icon[/bold] from a text prompt. + + [dim]Examples:[/dim] + freepik generate-icon "shopping cart" --style solid --format svg + freepik generate-icon "rocket ship" --style color --format png + """ + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + + with FreepikClient(key, base_url=config.base_url) as client: + api = EditAPI(client) + + with console.status("[info]Submitting icon generation…[/info]"): + try: + task_id = api.generate_icon(prompt, style, steps, guidance, seed) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "icon", f"text-to-icon/{style.value}") + return + + poll_config = PollConfig(task_type="icon", max_wait=config.poll_timeout) + try: + poll_task( + check_fn=lambda tid: api.icon_status(tid), + task_id=task_id, + config=poll_config, + console=console, + extra_info={"Style": style.value, "Format": fmt}, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + # Render to get download URL + with console.status(f"[info]Rendering icon as {fmt.upper()}…[/info]"): + try: + url = api.render_icon(task_id, fmt) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not url: + print_error("Icon generated but render URL not found.") + raise typer.Exit(1) + + out = output or auto_output_path("icon", style.value, fmt, config.default_output_dir) + save_from_url(url, out, console) + + print_result( + GenerationResult( + task_id=task_id, + model=f"text-to-icon/{style.value}", + output_path=out, + task_type="icon", + ) + ) diff --git a/freepik_cli/commands/upscale.py b/freepik_cli/commands/upscale.py new file mode 100644 index 0000000..cc7c3da --- /dev/null +++ b/freepik_cli/commands/upscale.py @@ -0,0 +1,243 @@ +"""upscale-image and upscale-video commands.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from freepik_cli.api.client import FreepikAPIError, FreepikClient +from freepik_cli.api.models import UpscaleMode, VideoUpscaleMode +from freepik_cli.api.upscale import UpscaleAPI +from freepik_cli.utils.config import FreepikConfig +from freepik_cli.utils.console import GenerationResult, console, print_error, print_no_wait, print_result +from freepik_cli.utils.files import auto_output_path, get_image_dimensions, image_to_base64, save_from_url, video_to_base64 +from freepik_cli.utils.polling import FreepikTaskError, FreepikTimeoutError, PollConfig, poll_task + + +def _get_api_key(api_key: Optional[str], config: FreepikConfig) -> str: + key = api_key or config.api_key + if not key: + print_error( + "No API key found.", + hint="Set [cyan]FREEPIK_API_KEY[/cyan] or pass [cyan]--api-key YOUR_KEY[/cyan].", + ) + raise typer.Exit(1) + return key + + +def upscale_image( + image: Annotated[ + Path, + typer.Argument( + help="Image file to upscale", + exists=True, file_okay=True, dir_okay=False, + ), + ], + mode: Annotated[ + UpscaleMode, + typer.Option( + "--mode", + help="Upscaling mode:\n" + " [cyan]precision-v2[/cyan] — faithful, detail-preserving (recommended)\n" + " [cyan]precision[/cyan] — faithful upscaling\n" + " [cyan]creative[/cyan] — AI-enhanced creative reinterpretation", + show_default=True, + ), + ] = UpscaleMode.PRECISION_V2, + scale: Annotated[ + str, + typer.Option("--scale", help="Scale factor: [cyan]2x[/cyan] or [cyan]4x[/cyan]"), + ] = "2x", + creativity: Annotated[ + Optional[int], + typer.Option( + "--creativity", + help="Creative enhancement level 0–10 ([cyan]creative[/cyan] mode only)", + min=0, max=10, + ), + ] = None, + prompt: Annotated[ + Optional[str], + typer.Option("--prompt", "-p", help="Enhancement guidance ([cyan]creative[/cyan] mode only)"), + ] = None, + seed: Annotated[ + Optional[int], + typer.Option("--seed", help="Random seed for reproducibility"), + ] = None, + output: Annotated[ + Optional[Path], + typer.Option("--output", "-o", help="Output file path"), + ] = None, + wait: Annotated[ + bool, + typer.Option("--wait/--no-wait", help="Wait for completion or return task ID immediately"), + ] = True, + api_key: Annotated[ + Optional[str], + typer.Option("--api-key", envvar="FREEPIK_API_KEY", help="Freepik API key"), + ] = None, +) -> None: + """ + [bold]Upscale and enhance an image[/bold] using AI. + + [dim]Examples:[/dim] + freepik upscale-image photo.jpg --mode precision-v2 --scale 4x + freepik upscale-image portrait.png --mode creative --creativity 7 --prompt "sharp cinematic" + """ + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + + if mode != UpscaleMode.CREATIVE and (creativity is not None or prompt): + print_error( + "--creativity and --prompt are only supported with --mode creative.", + hint="Switch to [cyan]--mode creative[/cyan] or remove those options.", + ) + raise typer.Exit(1) + + image_b64 = image_to_base64(image) + + with FreepikClient(key, base_url=config.base_url) as client: + api = UpscaleAPI(client) + + with console.status(f"[info]Submitting {mode.value} upscale ({scale})…[/info]"): + try: + task_id = api.upscale_image( + mode=mode, + image_b64=image_b64, + scale_factor=scale, + creativity=creativity, + prompt=prompt, + seed=seed, + ) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "upscale-image", mode.value) + return + + poll_config = PollConfig(task_type="upscale-image", max_wait=config.poll_timeout) + try: + result = poll_task( + check_fn=lambda tid: api.upscale_image_status(mode, tid), + task_id=task_id, + config=poll_config, + console=console, + extra_info={"Mode": mode.value, "Scale": scale}, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + urls = api.get_output_urls(result) + if not urls: + print_error("Upscaling completed but no output URL found.") + raise typer.Exit(1) + + out = output or auto_output_path("upscaled", mode.value, image.suffix.lstrip(".") or "jpg", config.default_output_dir) + save_from_url(urls[0], out, console) + + w, h = get_image_dimensions(out) + print_result( + GenerationResult( + task_id=task_id, + model=mode.value, + output_path=out, + width=w, + height=h, + task_type="upscale-image", + ) + ) + + +def upscale_video( + video: Annotated[ + Path, + typer.Argument( + help="Video file to upscale", + exists=True, file_okay=True, dir_okay=False, + ), + ], + mode: Annotated[ + VideoUpscaleMode, + typer.Option( + "--mode", + help="Upscaling mode: [cyan]standard[/cyan] | [cyan]turbo[/cyan] (faster)", + show_default=True, + ), + ] = VideoUpscaleMode.STANDARD, + output: Annotated[ + Optional[Path], + typer.Option("--output", "-o", help="Output video file path"), + ] = None, + wait: Annotated[ + bool, + typer.Option("--wait/--no-wait"), + ] = True, + api_key: Annotated[ + Optional[str], + typer.Option("--api-key", envvar="FREEPIK_API_KEY"), + ] = None, +) -> None: + """ + [bold]Upscale a video[/bold] to higher resolution using AI. + + [dim]Examples:[/dim] + freepik upscale-video clip.mp4 --mode standard + freepik upscale-video clip.mp4 --mode turbo --output clip_4k.mp4 + """ + config = FreepikConfig.load() + key = _get_api_key(api_key, config) + + video_b64 = video_to_base64(video) + + with FreepikClient(key, base_url=config.base_url) as client: + api = UpscaleAPI(client) + + with console.status(f"[info]Submitting video upscale ({mode.value})…[/info]"): + try: + task_id = api.upscale_video(mode=mode, video_b64=video_b64) + except FreepikAPIError as exc: + print_error(str(exc)) + raise typer.Exit(1) + + if not wait: + print_no_wait(task_id, "upscale-video", mode.value) + return + + poll_config = PollConfig( + task_type="upscale-video", + initial_delay=5.0, + max_wait=config.poll_timeout, + ) + try: + result = poll_task( + check_fn=lambda tid: api.upscale_video_status(tid), + task_id=task_id, + config=poll_config, + console=console, + extra_info={"Mode": mode.value}, + ) + except (FreepikTaskError, FreepikTimeoutError) as exc: + print_error(str(exc)) + raise typer.Exit(1) + + urls = api.get_output_urls(result) + if not urls: + print_error("Upscaling completed but no output URL found.") + raise typer.Exit(1) + + out = output or auto_output_path("upscaled_video", mode.value, "mp4", config.default_output_dir) + save_from_url(urls[0], out, console) + + print_result( + GenerationResult( + task_id=task_id, + model=mode.value, + output_path=out, + task_type="upscale-video", + ) + ) diff --git a/freepik_cli/main.py b/freepik_cli/main.py new file mode 100644 index 0000000..1c603a3 --- /dev/null +++ b/freepik_cli/main.py @@ -0,0 +1,123 @@ +"""Root typer application — command registration and banner.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from freepik_cli import __version__ +from freepik_cli.commands import config as config_cmd +from freepik_cli.commands.analyze import describe_image +from freepik_cli.commands.edit import expand_image, relight_image, style_transfer +from freepik_cli.commands.generate import generate_icon, generate_image, generate_video +from freepik_cli.commands.upscale import upscale_image, upscale_video +from freepik_cli.utils.console import console, print_banner +from freepik_cli.utils.config import FreepikConfig + +app = typer.Typer( + name="freepik", + help=( + "[bold magenta]Freepik AI[/bold magenta] — generate images, videos, and more " + "from the command line.\n\n" + "[dim]Set your API key:[/dim] [cyan]export FREEPIK_API_KEY=your_key[/cyan]\n" + "[dim]Get an API key:[/dim] https://www.freepik.com/api" + ), + rich_markup_mode="rich", + no_args_is_help=True, + pretty_exceptions_enable=True, + pretty_exceptions_show_locals=False, + pretty_exceptions_short=True, + add_completion=True, +) + +# ── Flat commands ────────────────────────────────────────────────────────────── + +app.command( + "generate-image", + help="[bold]Generate an image[/bold] from a text prompt using AI models.", + rich_help_panel="Generation", +)(generate_image) + +app.command( + "generate-video", + help="[bold]Generate a video[/bold] by animating a source image using AI.", + rich_help_panel="Generation", +)(generate_video) + +app.command( + "generate-icon", + help="[bold]Generate an icon[/bold] from a text prompt in various styles.", + rich_help_panel="Generation", +)(generate_icon) + +app.command( + "upscale-image", + help="[bold]Upscale and enhance[/bold] an image using AI.", + rich_help_panel="Enhancement", +)(upscale_image) + +app.command( + "upscale-video", + help="[bold]Upscale a video[/bold] to higher resolution using AI.", + rich_help_panel="Enhancement", +)(upscale_video) + +app.command( + "expand-image", + help="[bold]Expand an image[/bold] by adding new content around its edges (outpainting).", + rich_help_panel="Editing", +)(expand_image) + +app.command( + "relight", + help="[bold]Relight an image[/bold] using AI-controlled lighting. [dim](Premium)[/dim]", + rich_help_panel="Editing", +)(relight_image) + +app.command( + "style-transfer", + help="[bold]Apply an artistic style[/bold] from one image onto another. [dim](Premium)[/dim]", + rich_help_panel="Editing", +)(style_transfer) + +app.command( + "describe-image", + help="[bold]Describe an image[/bold] and generate a text prompt from it.", + rich_help_panel="Analysis", +)(describe_image) + +# ── Nested config sub-app ────────────────────────────────────────────────────── + +app.add_typer( + config_cmd.app, + rich_help_panel="Settings", +) + + +# ── Root callback ────────────────────────────────────────────────────────────── + +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + version: Annotated[ + bool, + typer.Option("--version", "-v", help="Show version and exit", is_eager=True), + ] = False, +) -> None: + """ + [bold magenta]Freepik AI[/bold magenta] — generate images, videos, icons, and more. + """ + cfg = FreepikConfig.load() + + if version: + print_banner() + console.print(f"[dim]Version:[/dim] [bold]{__version__}[/bold]\n") + raise typer.Exit() + + if ctx.invoked_subcommand is None: + if cfg.show_banner: + print_banner() + console.print(ctx.get_help()) + elif cfg.show_banner and ctx.invoked_subcommand not in ("config",): + print_banner() diff --git a/freepik_cli/utils/__init__.py b/freepik_cli/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freepik_cli/utils/config.py b/freepik_cli/utils/config.py new file mode 100644 index 0000000..b9e50d6 --- /dev/null +++ b/freepik_cli/utils/config.py @@ -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() diff --git a/freepik_cli/utils/console.py b/freepik_cli/utils/console.py new file mode 100644 index 0000000..90502e7 --- /dev/null +++ b/freepik_cli/utils/console.py @@ -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), + ) + ) diff --git a/freepik_cli/utils/files.py b/freepik_cli/utils/files.py new file mode 100644 index 0000000..f3da0ca --- /dev/null +++ b/freepik_cli/utils/files.py @@ -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 diff --git a/freepik_cli/utils/polling.py b/freepik_cli/utils/polling.py new file mode 100644 index 0000000..1feb032 --- /dev/null +++ b/freepik_cli/utils/polling.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..282e093 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "freepik-cli" +version = "0.1.0" +description = "A beautiful CLI for the Freepik AI API — generate images, videos, and more" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "Sebastian Krüger", email = "valknar@pivoine.art" }] + +dependencies = [ + "typer[all]>=0.12.0", + "rich>=13.7.0", + "httpx>=0.27.0", + "pydantic>=2.7.0", + "pydantic-settings>=2.3.0", + "toml>=0.10.2", + "platformdirs>=4.2.0", + "pillow>=10.3.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.2.0", + "ruff>=0.4.0", + "mypy>=1.10.0", +] + +[project.scripts] +freepik = "freepik_cli.main:app" + +[tool.hatch.build.targets.wheel] +packages = ["freepik_cli"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.11" +strict = false