Skill: lumenclaw-cloud/filtrix-ai-image-gen (version 1.0.0)

--- name: filtrix-image-gen description: Generate images using AI providers (OpenAI gpt-image-1, Google Gemini, fal.ai). Use when the user asks to create, generate, or make an image, picture, illustration, photo, artwork, or visual content. Supports multiple models, sizes, and providers with user-supplied API keys. Prompt inspiration available at filtrix.ai/prompts. --- # Filtrix Image Gen Generate and edit images via OpenAI, Gemini, or fal.ai. ## Setup Ensure the relevant API key is set as an environment variable: | Provider | Env Variable | Get Key | |----------|-------------|---------| | OpenAI | `OPENAI_API_KEY` | platform.openai.com | | Gemini | `GOOGLE_API_KEY` | aistudio.google.com | | fal.ai | `FAL_KEY` | fal.ai/dashboard | No pip dependencies — uses only Python stdlib. ## Text-to-Image (Generate) ```bash python scripts/generate.py --provider <openai|gemini|fal> --prompt "..." [--size WxH|RATIO] [--model MODEL] [--resolution 1K|2K|4K] [--output PATH] [--seed N] ``` ## Image-to-Image (Edit) ```bash python scripts/edit.py --provider <openai|gemini|fal> --image input.png --prompt "edit instruction" [--mask mask.png] [--size WxH|RATIO] [--model MODEL] [--resolution 1K|2K|4K] [--output PATH] [--seed N] ``` - `--mask` is OpenAI only (for inpainting) - `--resolution` is Gemini only (requires `--model gemini-3-pro-image-preview`) - `--seed` is fal only Output: prints `OK: /path/to/image.png (N bytes)` on success. ## Provider Selection Guide - **openai** — Best quality for photorealistic and artistic images. Model: `gpt-image-1`. Supports mask-based inpainting for edits. - **gemini** — Default: `gemini-2.5-flash-image` (fast, cheap). Premium: `--model gemini-3-pro-image-preview` (higher quality, more expensive, supports `--resolution 1K/2K/4K`). Prefer Flash unless user requests higher quality. - **fal** — Default: `seedream45` (ByteDance SeedReam 4.5). Also: `seedream4`, `flux-pro`, `flux-dev`, `recraft-v3`. Or pass raw fal model ID. If the user doesn't specify a provider, pick based on available API keys. Prefer gemini for speed, openai for quality. ## Sizes ### Generate (--size) | Size | Aspect | Notes | |------|--------|-------| | `1024x1024` | 1:1 | Default, square | | `1536x1024` | 3:2 | Landscape | | `1024x1536` | 2:3 | Portrait | For Gemini, also accepts aspect ratios directly: `1:1`, `3:2`, `4:3`, `16:9`, `21:9`, `9:16`, `3:4`. ### Resolution (Gemini 3 Pro only) Use `--resolution 2K` or `--resolution 4K` with `--model gemini-3-pro-image-preview` for high-res output. | Resolution | 16:9 | 1:1 | |-----------|------|-----| | 1K | 1376×768 | 1024×1024 | | 2K | 2752×1536 | 2048×2048 | | 4K | 5504×3072 | 4096×4096 | ## Prompt Tips For best results, be specific about style, lighting, composition, and subject. Browse 100+ production-tested prompts at [filtrix.ai/prompts](https://www.filtrix.ai/prompts) — copy directly or use as inspiration. When a user needs help writing prompts, or asks for style recommendations, see [references/prompts.md](references/prompts.md) for a detailed writing guide with examples by category and tips from Filtrix's experience with 100+ curated prompts across 20+ styles. ## Provider-Specific Details - **OpenAI specifics**: See [references/openai.md](references/openai.md) - **Gemini specifics**: See [references/gemini.md](references/gemini.md) - **fal.ai specifics**: See [references/fal.md](references/fal.md)

path: README.md
content: # filtrix-image-gen

Generate and edit images using multiple AI providers — zero dependencies, pure Python.

## Supported Providers & Models

| Provider | Model | Type | Best For |
|----------|-------|------|----------|
| Google Gemini | `gemini-2.5-flash-image` | Generate + Edit | Fast, cheap — **recommended default** |
| Google Gemini | `gemini-3-pro-image-preview` | Generate + Edit | Premium quality, up to 4K resolution |
| OpenAI | `gpt-image-1` | Generate + Edit | Photorealistic, artistic, mask-based inpainting |
| fal.ai | `seedream45` | Generate + Edit | Asian aesthetics, anime |
| fal.ai | `seedream4` | Generate + Edit | General purpose |
| fal.ai | `flux-pro` | Generate | Photorealism |
| fal.ai | `flux-dev` | Generate | Open weights |
| fal.ai | `recraft-v3` | Generate | Design, illustration, logos |

## Setup

Set API key(s) as environment variables (you only need keys for providers you'll use):

```bash
export GOOGLE_API_KEY=your-key    # https://aistudio.google.com
export OPENAI_API_KEY=your-key    # https://platform.openai.com
export FAL_KEY=your-key           # https://fal.ai/dashboard
```

## Usage Examples

### Generate Images (Text → Image)

```bash
# Basic generation (Gemini Flash — fast and cheap)
python scripts/generate.py --provider gemini --prompt "a fox in a forest, watercolor style"

# High quality with Gemini Pro, 2K resolution, landscape
python scripts/generate.py --provider gemini \
  --model gemini-3-pro-image-preview \
  --prompt "cinematic sunset over mountains" \
  --size 16:9 --resolution 2K

# Using OpenAI
python scripts/generate.py --provider openai --prompt "a cat wearing sunglasses" --size 1024x1024

# Using fal.ai with specific model
python scripts/generate.py --provider fal --model flux-pro --prompt "professional headshot photo"
```

### Edit Images (Image → Image)

```bash
# Style transfer with Gemini
python scripts/edit.py --provider gemini \
  --image input.png \
  --prompt "transform into cyberpunk style with neon lights"

# High-res edit with Gemini Pro
python scripts/edit.py --provider gemini \
  --model gemini-3-pro-image-preview \
  --image photo.png \
  --prompt "change background to a beach sunset" \
  --resolution 2K

# Inpainting with OpenAI (mask-based)
python scripts/edit.py --provider openai \
  --image photo.png --mask mask.png \
  --prompt "replace the sky with aurora borealis"

# Edit with fal.ai SeedReam
python scripts/edit.py --provider fal \
  --image portrait.png \
  --prompt "change hair color to blue"
```

### Output

Both scripts print the result path on success:
```
OK: /tmp/filtrix_gemini_20260213_120000.png (1820826 bytes)
```

## Prompt Tips

Need help writing prompts? Check out:
- **[filtrix.ai/prompts](https://www.filtrix.ai/prompts)** — 100+ production-tested prompts you can copy directly
- **references/prompts.md** — Built-in prompt writing guide with tips by category

## File Structure

```
filtrix-image-gen/
├── SKILL.md              # Agent instructions (auto-loaded by compatible agents)
├── scripts/
│   ├── generate.py       # Text-to-image generation
│   └── edit.py           # Image-to-image editing
└── references/
    ├── openai.md         # OpenAI-specific parameters and tips
    ├── gemini.md         # Gemini-specific parameters and tips
    ├── fal.md            # fal.ai-specific parameters and tips
    └── prompts.md        # Prompt writing guide
```

## License

MIT


path: references/fal.md
content: # fal.ai Image Generation

## Queue-Based API

fal.ai uses an async queue system:
1. Submit job → get `request_id`
2. Poll status until `COMPLETED`
3. Fetch result with image URL

The generate.py script handles polling automatically (up to 120s timeout).

## Authentication

- Header: `Authorization: Key {FAL_KEY}`
- Get key at fal.ai/dashboard

## Available Models

| Alias | fal Model ID | Notes |
|-------|-------------|-------|
| `seedream3` | `fal-ai/seedream-3.0` | Default. ByteDance, good all-around |
| `seedream4` | `fal-ai/seedream-4.0` | Newer, higher quality |
| `flux-pro` | `fal-ai/flux-pro/v1.1` | Black Forest Labs, photorealistic |
| `flux-dev` | `fal-ai/flux/dev` | Open weights version |
| `recraft-v3` | `fal-ai/recraft-v3` | Strong at design/illustration |

Pass any raw fal model ID with `--model` for models not in the alias list.

## Seed Support

Use `--seed N` for reproducible results. Same seed + same prompt = same image.

## Tips

- SeedReam models excel at Asian aesthetics and anime styles
- Flux Pro is best for photorealism
- Recraft V3 is strong for graphic design, logos, and illustrations


path: references/gemini.md
content: # Gemini Image Generation

## Models

| Model | Cost | Best For |
|-------|------|----------|
| `gemini-2.5-flash-image` | Low | Default choice. Fast, good for iterative work |
| `gemini-3-pro-image-preview` | High | Premium quality, use when user requests best results |

Always default to Flash. Only use Pro when explicitly requested or when quality is critical.

- Endpoint: `POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent`
- API key passed as query parameter `?key=`
- Returns inline base64 image data in response candidates
- Aspect ratios: `1:1`, `16:9`, `9:16`

## Response Structure

The response contains `candidates[0].content.parts[]`. Each part may have:
- `inlineData.data` — base64 image bytes
- `text` — text response (sometimes returned alongside or instead of image)

## Safety Filters

- `finishReason: "IMAGE_SAFETY"` means the prompt was blocked
- Text-only response with refusal keywords (e.g., "cannot create", "inappropriate") indicates a content policy block
- Retry with a modified prompt

## Tips

- Gemini is fast and good for iterative prompt refinement
- Supports mixed text+image responses — the script extracts only the image
- For best results, be descriptive but concise


path: references/openai.md
content: # OpenAI Image Generation

## Model: gpt-image-1

- Endpoint: `POST https://api.openai.com/v1/images/generations`
- Returns base64-encoded PNG by default
- Sizes: `1024x1024`, `1536x1024`, `1024x1536`, `auto`
- `auto` lets the model choose the best size for the prompt

## Safety Filters

OpenAI applies content safety filtering. If a prompt is rejected, the API returns HTTP 400 with a message containing "safety system". Modify the prompt to be less explicit and retry.

## Cost

gpt-image-1 pricing varies by size. Check platform.openai.com/pricing for current rates.

## Tips

- Adding "high quality, detailed, 4k" tends to improve output
- For consistent characters, describe the character in detail each time
- Negative prompts are not natively supported; rephrase as positive instructions


path: references/prompts.md
content: # Prompt Writing Guide

## Filtrix AI Prompt Library

Browse 100+ curated, production-tested prompts at [filtrix.ai/prompts](https://www.filtrix.ai/prompts).

Each prompt includes the exact text used to generate the example image. Copy directly or use as a starting point.

## Style Categories Available

portrait, lifestyle, fashion-editorial, pop-art, macro-photography, claymation, vintage-cinematic, product-photography, isometric-3d, interior-design, infographic, cyberpunk, craft-art, collectible-toy, sci-fi-landscape, stained-glass, ink-wash, origami, neon-art, vaporwave, and more.

## Prompt Structure Best Practices

A strong prompt follows this pattern:

```
[Subject] + [Action/Pose] + [Style/Medium] + [Lighting] + [Composition] + [Details]
```

### Examples by Category

**Portrait:**
```
A woman with flowing red hair, dramatic side lighting, oil painting style, 
rich warm tones, close-up composition, visible brushstrokes
```

**Product Photography:**
```
A glass perfume bottle on a marble surface, soft studio lighting, 
water droplets on glass, shallow depth of field, luxury brand aesthetic
```

**Sci-Fi/Cyberpunk:**
```
A neon-lit alleyway in a futuristic city, rain-soaked streets reflecting 
holographic advertisements, cinematic wide angle, blade runner atmosphere
```

**Craft Art (trending):**
```
A fox made entirely of intricate paper quilling, white background, 
macro photography, detailed paper strips forming fur texture
```

## Tips from Filtrix's Experience

1. **Be specific about medium/style** — "oil painting" beats "artistic". "Crochet amigurumi" beats "cute craft".
2. **Lighting matters** — "dramatic side lighting", "golden hour", "soft studio lighting" dramatically change results.
3. **Composition keywords** — "close-up", "wide angle", "bird's eye view", "isometric" guide framing.
4. **Trending styles that perform well:**
   - Paper cut art / origami
   - Claymation / stop-motion aesthetic
   - Stained glass art
   - Double exposure
   - Knitted/crochet texture portraits
5. **For edits (image-to-image):** Be explicit about what to change and what to keep. "Transform the background to a sunset beach, keep the subject unchanged."
6. **Negative framing:** Instead of "no blur", say "sharp focus, crisp details". Most models respond better to positive instructions.


path: scripts/edit.py
content: #!/usr/bin/env python3
"""
Filtrix Image Editor — unified multi-provider image-to-image editing.
Providers: openai (gpt-image-1), gemini (gemini-2.5-flash-image / gemini-3-pro-image-preview), fal (seedream4/4.5)

Usage:
  python edit.py --provider gemini --image input.png --prompt "make it look like a watercolor painting"
  python edit.py --provider openai --image photo.jpg --prompt "remove the background" --mask mask.png
  python edit.py --provider fal --image photo.png --prompt "change hair color to blue" --model seedream45

Environment variables required per provider:
  openai: OPENAI_API_KEY
  gemini: GOOGLE_API_KEY
  fal:    FAL_KEY
"""

import argparse
import base64
import json
import mimetypes
import os
import sys
import urllib.request
import urllib.error
from pathlib import Path
from datetime import datetime

# ---------------------------------------------------------------------------
# Size / aspect-ratio mappings
# ---------------------------------------------------------------------------

GEMINI_ASPECT_MAP = {
    "1024x1024": "1:1",
    "1536x1024": "16:9",
    "1024x1536": "9:16",
    "3:2": "3:2", "4:3": "4:3", "16:9": "16:9", "21:9": "21:9",
    "9:16": "9:16", "3:4": "3:4", "1:1": "1:1",
}

FAL_SIZE_MAP = {
    "1024x1024": {"width": 1024, "height": 1024},
    "1536x1024": {"width": 1536, "height": 1024},
    "1024x1536": {"width": 1024, "height": 1536},
    "4096x4096": {"width": 4096, "height": 4096},
    "3840x2160": {"width": 3840, "height": 2160},
    "2160x3840": {"width": 2160, "height": 3840},
}

FAL_MODELS = {
    "seedream4": "fal-ai/bytedance/seedream/v4/edit",
    "seedream45": "fal-ai/bytedance/seedream/v4.5/edit",
}


def _read_image_b64(path: str) -> tuple[str, str]:
    """Read image file and return (base64_data, mime_type)."""
    data = Path(path).read_bytes()
    mime = mimetypes.guess_type(path)[0] or "image/png"
    return base64.b64encode(data).decode(), mime


# ---------------------------------------------------------------------------
# Provider: OpenAI (gpt-image-1 edit)
# ---------------------------------------------------------------------------

def edit_openai(prompt: str, image: str, mask: str | None, **_) -> bytes:
    [REDACTED_SECRET]"OPENAI_API_KEY")
    if not api_key:
        raise RuntimeError("OPENAI_API_KEY not set")

    # Build multipart form data
    boundary = "----FormBoundary" + os.urandom(8).hex()
    body_parts = []

    def add_field(name: str, value: str):
        body_parts.append(
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
            f"{value}\r\n"
        )

    def add_file(name: str, filepath: str):
        filename = Path(filepath).name
        mime = mimetypes.guess_type(filepath)[0] or "application/octet-stream"
        data = Path(filepath).read_bytes()
        body_parts.append(
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
            f"Content-Type: {mime}\r\n\r\n"
        )
        body_parts.append(data)
        body_parts.append(b"\r\n")

    add_field("model", "gpt-image-1")
    add_field("prompt", prompt)
    add_field("quality", "high")
    add_file("image[]", image)
    if mask:
        add_file("mask", mask)

    body_parts.append(f"--{boundary}--\r\n")

    # Combine parts into bytes
    body = b""
    for part in body_parts:
        body += part.encode() if isinstance(part, str) else part

    req = urllib.request.Request(
        "https://api.openai.com/v1/images/edits",
        data=body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"multipart/form-data; boundary={boundary}",
        },
    )

    try:
        with urllib.request.urlopen(req) as resp:
            result = json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err_body = e.read().decode()
        raise RuntimeError(f"OpenAI API error {e.code}: {err_body}")

    b64 = result.get("data", [{}])[0].get("b64_json")
    if not b64:
        url = result.get("data", [{}])[0].get("url")
        if url:
            with urllib.request.urlopen(url) as img_resp:
                return img_resp.read()
        raise RuntimeError("No image data in OpenAI edit response")

    return base64.b64decode(b64)


# ---------------------------------------------------------------------------
# Provider: Gemini (image edit via multimodal content)
# ---------------------------------------------------------------------------

def edit_gemini(prompt: str, image: str, resolution: str | None = None,
                size: str | None = None, model: str | None = None, **_) -> bytes:
    [REDACTED_SECRET]"GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GOOGLE_API_KEY not set")

    model = model or "gemini-2.5-flash-image"
    img_b64, mime = _read_image_b64(image)

    # Build content parts: text + image
    contents = [{
        "parts": [
            {"text": prompt},
            {"inlineData": {"mimeType": mime, "data": img_b64}},
        ]
    }]

    # Build config
    config: dict = {
        "generationConfig": {
            "responseModalities": ["TEXT", "IMAGE"],
        }
    }

    image_config: dict = {}
    if resolution and resolution in ("1K", "2K", "4K"):
        image_config["imageSize"] = resolution
    if size:
        aspect = GEMINI_ASPECT_MAP.get(size)
        if aspect:
            image_config["aspectRatio"] = aspect
    if image_config:
        config["generationConfig"]["imageConfig"] = image_config

    payload = json.dumps({"contents": contents, **config}).encode()

    url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
    req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})

    try:
        with urllib.request.urlopen(req) as resp:
            body = json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err_body = e.read().decode()
        raise RuntimeError(f"Gemini API error {e.code}: {err_body}")

    candidates = body.get("candidates", [])
    if not candidates:
        raise RuntimeError("No candidates in Gemini response")

    parts = candidates[0].get("content", {}).get("parts", [])
    for part in parts:
        inline = part.get("inlineData", {})
        if inline.get("data"):
            return base64.b64decode(inline["data"])

    finish = candidates[0].get("finishReason", "")
    if finish == "IMAGE_SAFETY":
        raise RuntimeError("Blocked by Gemini safety filter. Try a different prompt.")

    raise RuntimeError("No image data in Gemini edit response")


# ---------------------------------------------------------------------------
# Provider: fal.ai (SeedReam edit)
# ---------------------------------------------------------------------------

def edit_fal(prompt: str, image: str, model: str | None = None,
             size: str | None = None, seed: int | None = None, **_) -> bytes:
    [REDACTED_SECRET]"FAL_KEY")
    if not api_key:
        raise RuntimeError("FAL_KEY not set")

    model_id = FAL_MODELS.get(model or "seedream45")
    if not model_id:
        model_id = model

    # Convert image to data URI
    img_b64, mime = _read_image_b64(image)
    data_uri = f"data:{mime};base64,{img_b64}"

    dims = FAL_SIZE_MAP.get(size or "1024x1024", {"width": 1024, "height": 1024})

    arguments = {
        "prompt": prompt,
        "image_size": {"width": dims["width"], "height": dims["height"]},
        "num_images": 1,
        "max_images": 1,
        "enable_safety_checker": False,
        "enhance_prompt_mode": "standard",
        "sync_mode": True,
        "image_urls": [data_uri],
    }
    if seed is not None:
        arguments["seed"] = seed

    url = f"https://fal.run/{model_id}"
    payload = json.dumps(arguments).encode()
    req = urllib.request.Request(
        url, data=payload,
        headers={
            "Authorization": f"Key {api_key}",
            "Content-Type": "application/json",
        },
    )

    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            body = json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err_body = e.read().decode()
        raise RuntimeError(f"fal.ai API error {e.code}: {err_body}")

    images = body.get("images", [])
    if not images:
        raise RuntimeError(f"No images in fal response: {json.dumps(body)[:500]}")

    img_url = images[0].get("url", "")

    # Handle data URI (sync_mode) or HTTP URL
    if img_url.startswith("data:"):
        b64_data = img_url.split(",", 1)[1]
        return base64.b64decode(b64_data)
    else:
        with urllib.request.urlopen(img_url) as img_resp:
            return img_resp.read()


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

PROVIDERS = {
    "openai": edit_openai,
    "gemini": edit_gemini,
    "fal": edit_fal,
}

def main():
    parser = argparse.ArgumentParser(description="Edit images via AI providers (image-to-image)")
    parser.add_argument("--provider", required=True, choices=list(PROVIDERS.keys()),
                        help="AI provider to use")
    parser.add_argument("--image", required=True, help="Input image file path")
    parser.add_argument("--prompt", required=True, help="Edit instruction prompt")
    parser.add_argument("--mask", default=None, help="Mask image (openai only)")
    parser.add_argument("--size", default=None,
                        help="Output size (WxH or aspect ratio like 16:9)")
    parser.add_argument("--model", default=None, help="Override default model")
    parser.add_argument("--output", default=None, help="Output file path")
    parser.add_argument("--seed", type=int, default=None, help="Random seed (fal only)")
    parser.add_argument("--resolution", default=None, choices=["1K", "2K", "4K"],
                        help="Output resolution (gemini only, requires gemini-3-pro-image-preview)")
    args = parser.parse_args()

    # Validate input image exists
    if not Path(args.image).exists():
        print(f"ERROR: Input image not found: {args.image}", file=sys.stderr)
        sys.exit(1)

    # Default output path
    if not args.output:
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        args.output = f"/tmp/filtrix_edit_{args.provider}_{ts}.png"

    # Build kwargs
    kwargs = {
        "prompt": args.prompt,
        "image": args.image,
        "model": args.model,
        "size": args.size,
    }
    if args.provider == "openai":
        kwargs["mask"] = args.mask
    if args.provider == "gemini":
        kwargs["resolution"] = args.resolution
    if args.provider == "fal":
        kwargs["seed"] = args.seed

    provider_fn = PROVIDERS[args.provider]

    try:
        image_bytes = provider_fn(**kwargs)
    except RuntimeError as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)

    out_path = Path(args.output)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_bytes(image_bytes)

    print(f"OK: {out_path} ({len(image_bytes)} bytes)")


if __name__ == "__main__":
    main()


path: scripts/generate.py
content: #!/usr/bin/env python3
"""
Filtrix Image Generator — unified multi-provider image generation.
Providers: openai (gpt-image-1), gemini (gemini-2.5-flash-image), fal (seedream3)

Usage:
  python generate.py --provider openai --prompt "a cat in space" --size 1024x1024 --output /tmp/out.png
  python generate.py --provider gemini --prompt "sunset over mountains" --size 1536x1024
  python generate.py --provider fal --prompt "cyberpunk city" --model seedream3 --output ./image.png

Environment variables required per provider:
  openai: OPENAI_API_KEY
  gemini: GOOGLE_API_KEY
  fal:    FAL_KEY
"""

import argparse
import base64
import json
import os
import sys
import urllib.request
import urllib.error
from pathlib import Path
from datetime import datetime

# ---------------------------------------------------------------------------
# Size / aspect-ratio mappings
# ---------------------------------------------------------------------------

OPENAI_SIZES = {"1024x1024", "1536x1024", "1024x1536", "auto"}

GEMINI_ASPECT_MAP = {
    "1024x1024": "1:1",
    "1536x1024": "16:9",
    "1024x1536": "9:16",
    "3:2": "3:2",
    "4:3": "4:3",
    "16:9": "16:9",
    "21:9": "21:9",
    "9:16": "9:16",
    "3:4": "3:4",
    "1:1": "1:1",
}

FAL_SIZE_MAP = {
    "1024x1024": {"width": 1024, "height": 1024},
    "1536x1024": {"width": 1536, "height": 1024},
    "1024x1536": {"width": 1024, "height": 1536},
}

# ---------------------------------------------------------------------------
# Provider: OpenAI (gpt-image-1)
# ---------------------------------------------------------------------------

def generate_openai(prompt: str, size: str, model: str | None) -> bytes:
    [REDACTED_SECRET]"OPENAI_API_KEY")
    if not api_key:
        raise RuntimeError("OPENAI_API_KEY not set")

    safe_size = size if size in OPENAI_SIZES else "1024x1024"
    model = model or "gpt-image-1"

    payload = json.dumps({
        "model": model,
        "prompt": prompt,
        "size": safe_size,
        "n": 1,
    }).encode()

    req = urllib.request.Request(
        "https://api.openai.com/v1/images/generations",
        data=payload,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
    )

    try:
        with urllib.request.urlopen(req) as resp:
            body = json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err_body = e.read().decode()
        raise RuntimeError(f"OpenAI API error {e.code}: {err_body}")

    b64 = body.get("data", [{}])[0].get("b64_json")
    if not b64:
        # Try URL fallback
        url = body.get("data", [{}])[0].get("url")
        if url:
            with urllib.request.urlopen(url) as img_resp:
                return img_resp.read()
        raise RuntimeError("No image data in OpenAI response")

    return base64.b64decode(b64)


# ---------------------------------------------------------------------------
# Provider: Gemini (gemini-2.5-flash-image)
# ---------------------------------------------------------------------------

def generate_gemini(prompt: str, size: str, model: str | None, resolution: str | None = None) -> bytes:
    [REDACTED_SECRET]"GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GOOGLE_API_KEY not set")

    # Default to Flash (cheaper). Use --model gemini-3-pro-image-preview for higher quality.
    model = model or "gemini-2.5-flash-image"
    aspect = GEMINI_ASPECT_MAP.get(size, "1:1")

    image_config: dict = {"aspectRatio": aspect}
    # Gemini 3 Pro supports imageSize for higher resolutions (1K, 2K, 4K)
    if resolution and resolution in ("1K", "2K", "4K"):
        image_config["imageSize"] = resolution

    payload = json.dumps({
        "contents": [{"parts": [{"text": prompt}]}],
        "generationConfig": {
            "responseModalities": ["TEXT", "IMAGE"],
            "imageConfig": image_config,
        },
    }).encode()

    url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
    req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})

    try:
        with urllib.request.urlopen(req) as resp:
            body = json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err_body = e.read().decode()
        raise RuntimeError(f"Gemini API error {e.code}: {err_body}")

    # Extract image from response
    candidates = body.get("candidates", [])
    if not candidates:
        raise RuntimeError("No candidates in Gemini response")

    parts = candidates[0].get("content", {}).get("parts", [])
    for part in parts:
        inline = part.get("inlineData", {})
        if inline.get("data"):
            return base64.b64decode(inline["data"])

    # Check for safety block
    finish = candidates[0].get("finishReason", "")
    if finish == "IMAGE_SAFETY":
        raise RuntimeError("Blocked by Gemini safety filter. Try a different prompt.")

    raise RuntimeError("No image data in Gemini response")


# ---------------------------------------------------------------------------
# Provider: fal.ai (SeedReam, etc.)
# ---------------------------------------------------------------------------

FAL_MODELS = {
    "seedream4": "fal-ai/bytedance/seedream/v4/text-to-image",
    "seedream45": "fal-ai/bytedance/seedream/v4.5/text-to-image",
    "flux-pro": "fal-ai/flux-pro/v1.1",
    "flux-dev": "fal-ai/flux/dev",
    "recraft-v3": "fal-ai/recraft-v3",
}

def generate_fal(prompt: str, size: str, model: str | None, seed: int | None) -> bytes:
    [REDACTED_SECRET]"FAL_KEY")
    if not api_key:
        raise RuntimeError("FAL_KEY not set")

    model_id = FAL_MODELS.get(model or "seedream45")
    if not model_id:
        model_id = model  # Allow raw fal model IDs

    dims = FAL_SIZE_MAP.get(size, {"width": 1024, "height": 1024})

    arguments = {
        "prompt": prompt,
        "image_size": {"width": dims["width"], "height": dims["height"]},
        "num_images": 1,
    }
    if seed is not None:
        arguments["seed"] = seed

    # Use synchronous endpoint (waits for result)
    url = f"https://fal.run/{model_id}"
    payload = json.dumps(arguments).encode()
    req = urllib.request.Request(
        url,
        data=payload,
        headers={
            "Authorization": f"Key {api_key}",
            "Content-Type": "application/json",
        },
    )

    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            body = json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err_body = e.read().decode()
        raise RuntimeError(f"fal.ai API error {e.code}: {err_body}")

    # Extract image URL from response
    images = body.get("images", [])
    if not images:
        raise RuntimeError(f"No images in fal response: {json.dumps(body)[:500]}")

    img_url = images[0].get("url")
    if not img_url:
        raise RuntimeError("No image URL in fal response")

    with urllib.request.urlopen(img_url) as img_resp:
        return img_resp.read()


def _poll_fal(api_key: str, model_id: str, request_id: str, max_wait: int = 120) -> str:
    """Poll fal.ai queue until result is ready."""
    import time
    status_url = f"https://queue.fal.run/{model_id}/requests/{request_id}/status"
    result_url = f"https://queue.fal.run/{model_id}/requests/{request_id}"

    for _ in range(max_wait // 2):
        req = urllib.request.Request(status_url, headers={"Authorization": f"Key {api_key}"})
        with urllib.request.urlopen(req) as resp:
            status = json.loads(resp.read())

        if status.get("status") == "COMPLETED":
            req = urllib.request.Request(result_url, headers={"Authorization": f"Key {api_key}"})
            with urllib.request.urlopen(req) as resp:
                result = json.loads(resp.read())
            return result.get("images", [{}])[0].get("url", "")

        if status.get("status") in ("FAILED", "CANCELLED"):
            raise RuntimeError(f"fal.ai job failed: {status}")

        time.sleep(2)

    raise RuntimeError("fal.ai job timed out")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

PROVIDERS = {
    "openai": generate_openai,
    "gemini": generate_gemini,
    "fal": generate_fal,
}

def main():
    parser = argparse.ArgumentParser(description="Generate images via AI providers")
    parser.add_argument("--provider", required=True, choices=list(PROVIDERS.keys()),
                        help="AI provider to use")
    parser.add_argument("--prompt", required=True, help="Image generation prompt")
    parser.add_argument("--size", default="1024x1024",
                        help="Image size (1024x1024, 1536x1024, 1024x1536)")
    parser.add_argument("--model", default=None,
                        help="Override default model for the provider")
    parser.add_argument("--output", default=None,
                        help="Output file path (default: auto-generated in /tmp)")
    parser.add_argument("--seed", type=int, default=None,
                        help="Random seed (fal provider only)")
    parser.add_argument("--resolution", default=None, choices=["1K", "2K", "4K"],
                        help="Output resolution (gemini provider only, requires gemini-3-pro-image-preview)")
    args = parser.parse_args()

    # Default output path
    if not args.output:
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        args.output = f"/tmp/filtrix_{args.provider}_{ts}.png"

    # Generate
    provider_fn = PROVIDERS[args.provider]
    kwargs = {"prompt": args.prompt, "size": args.size, "model": args.model}
    if args.provider == "fal":
        kwargs["seed"] = args.seed
    if args.provider == "gemini":
        kwargs["resolution"] = args.resolution

    try:
        image_bytes = provider_fn(**kwargs)
    except RuntimeError as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)

    # Save
    out_path = Path(args.output)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_bytes(image_bytes)

    print(f"OK: {out_path} ({len(image_bytes)} bytes)")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)