Skill: nhannah/venice-ai-media (version 1.0.2)

--- name: venice-ai-media description: Generate, edit, and upscale images; create videos from images via Venice AI. Supports text-to-image, image-to-video (Sora, WAN), upscaling, and AI editing. homepage: https://venice.ai metadata: { "clawdbot": { "emoji": "🎨", "requires": { "bins": ["python3"], "env": ["VENICE_API_KEY"] }, "primaryEnv": "VENICE_API_KEY", "notes": "Requires Python 3.10+", "install": [ { "id": "python-brew", "kind": "brew", "formula": "python", "bins": ["python3"], "label": "Install Python (brew)", }, ], }, } --- # Venice AI Media Generate images and videos using Venice AI APIs. Venice is an uncensored AI platform with competitive pricing. ## Prerequisites - **Python 3.10+** (`brew install python` or system Python) - **Venice API key** (free tier available) - **requests** library (auto-installed by scripts if missing) ## Setup ### 1. Get Your API Key 1. Create account at [venice.ai](https://venice.ai) 2. Go to [venice.ai/settings/api](https://venice.ai/settings/api) 3. Click "Create API Key" 4. Copy the key (starts with `vn_...`) ### 2. Configure the Key **Option A: Environment variable** ```bash export VENICE_API_KEY="vn_your_key_here" ``` **Option B: Clawdbot config** (recommended - persists across sessions) Add to `~/.clawdbot/clawdbot.json`: ```json5 { skills: { entries: { "venice-ai-media": { env: { VENICE_API_KEY: "vn_your_key_here", }, }, }, }, } ``` ### 3. Verify Setup ```bash python3 {baseDir}/scripts/venice-image.py --list-models ``` If you see a list of models, you're ready! ## Pricing Overview | Feature | Cost | | ---------------- | --------------------------------- | | Image generation | ~$0.01-0.03 per image | | Image upscale | ~$0.02-0.04 | | Image edit | $0.04 | | Video (WAN) | ~$0.10-0.50 depending on duration | | Video (Sora) | ~$0.50-2.00 depending on duration | Use `--quote` with video commands to check pricing before generation. ## Quick Start ```bash # Generate an image python3 {baseDir}/scripts/venice-image.py --prompt "a serene canal in Venice at sunset" # Upscale an image python3 {baseDir}/scripts/venice-upscale.py photo.jpg --scale 2 # Edit an image with AI python3 {baseDir}/scripts/venice-edit.py photo.jpg --prompt "add sunglasses" # Create a video from an image python3 {baseDir}/scripts/venice-video.py --image photo.jpg --prompt "gentle camera pan" --duration 5s ``` --- ## Image Generation ```bash python3 {baseDir}/scripts/venice-image.py --prompt "a serene canal in Venice at sunset" python3 {baseDir}/scripts/venice-image.py --prompt "cyberpunk city" --count 4 python3 {baseDir}/scripts/venice-image.py --prompt "portrait" --width 768 --height 1024 python3 {baseDir}/scripts/venice-image.py --prompt "abstract art" --out-dir /tmp/venice python3 {baseDir}/scripts/venice-image.py --list-models python3 {baseDir}/scripts/venice-image.py --list-styles python3 {baseDir}/scripts/venice-image.py --prompt "fantasy" --model flux-2-pro --no-validate python3 {baseDir}/scripts/venice-image.py --prompt "photo" --style-preset "Cinematic" --embed-exif ``` **Key flags:** `--prompt`, `--model` (default: flux-2-max), `--count` (uses efficient batch API for same prompt), `--width`, `--height`, `--format` (webp/png/jpeg), `--resolution` (1K/2K/4K), `--aspect-ratio`, `--negative-prompt`, `--style-preset` (use `--list-styles` to see options), `--cfg-scale` (prompt adherence 0-20, default 7.5), `--seed` (for reproducible results), `--safe-mode` (disabled by default for uncensored output), `--hide-watermark` (only use if explicitly requested - watermark supports Venice), `--embed-exif` (embed prompt in image metadata), `--lora-strength` (0-100 for applicable models), `--steps` (inference steps, model-dependent), `--enable-web-search`, `--no-validate` (skip model check for new/beta models) ## Image Upscale ```bash python3 {baseDir}/scripts/venice-upscale.py photo.jpg --scale 2 python3 {baseDir}/scripts/venice-upscale.py photo.jpg --scale 4 --enhance python3 {baseDir}/scripts/venice-upscale.py photo.jpg --enhance --enhance-prompt "sharpen details" python3 {baseDir}/scripts/venice-upscale.py --url "https://example.com/image.jpg" --scale 2 ``` **Key flags:** `--scale` (1-4, default: 2), `--enhance` (AI enhancement), `--enhance-prompt`, `--enhance-creativity` (0.0-1.0), `--replication` (0.0-1.0, preserves lines/noise, default: 0.35), `--url` (use URL instead of local file), `--output`, `--out-dir` ## Image Edit ```bash python3 {baseDir}/scripts/venice-edit.py photo.jpg --prompt "add sunglasses" python3 {baseDir}/scripts/venice-edit.py photo.jpg --prompt "change the sky to sunset" python3 {baseDir}/scripts/venice-edit.py photo.jpg --prompt "remove the person in background" python3 {baseDir}/scripts/venice-edit.py --url "https://example.com/image.jpg" --prompt "colorize" ``` **Key flags:** `--prompt` (required - AI interprets what to modify), `--url` (use URL instead of local file), `--output`, `--out-dir` **Note:** The edit endpoint uses the Qwen-Image model which has some content restrictions (unlike other Venice endpoints). ## Video Generation ```bash # Get price quote first (no generation) python3 {baseDir}/scripts/venice-video.py --quote --model wan-2.6-image-to-video --duration 10s --resolution 720p # Image-to-video (WAN 2.6 - default) python3 {baseDir}/scripts/venice-video.py --image photo.jpg --prompt "camera pans slowly" --duration 10s # Image-to-video (Sora) python3 {baseDir}/scripts/venice-video.py --image photo.jpg --prompt "cinematic" \ --model sora-2-image-to-video --duration 8s --aspect-ratio 16:9 --skip-audio-param # List models (shows available durations per model) python3 {baseDir}/scripts/venice-video.py --list-models # Clean up a video downloaded with --no-delete python3 {baseDir}/scripts/venice-video.py --complete <queue_id> --model <model> ``` **Key flags:** `--image` (required for generation), `--prompt` (required for generation), `--model` (default: wan-2.6-image-to-video), `--duration` (model-dependent, see --list-models), `--resolution` (480p/720p/1080p), `--aspect-ratio`, `--audio`/`--no-audio`, `--skip-audio-param`, `--quote` (price estimate), `--timeout`, `--poll-interval`, `--no-delete` (keep server media), `--complete` (cleanup previously downloaded video), `--no-validate` (skip model check) **Progress:** During generation, the script shows estimated progress based on Venice's average execution time. ## Model Notes Use `--list-models` to see current availability and status. Models change frequently. **Image:** Default is `flux-2-max`. Common options include flux, gpt-image, and nano-banana variants. **Video:** - **WAN** models: Image-to-video, configurable audio, various durations (5s-21s) - **Sora** models: Requires `--aspect-ratio`, use `--skip-audio-param` **Tips:** - Use `--no-validate` for new or beta models not yet in the model list - Use `--quote` for video to check pricing before generation - Safe mode is disabled by default (Venice is an uncensored API) ## Output Scripts print a `MEDIA: /path/to/file` line for Clawdbot auto-attach. **Tip:** Use `--out-dir /tmp/venice-$(date +%s)` when generating media to send via iMessage (ensures accessibility across user accounts). ## Troubleshooting **"VENICE_API_KEY not set"** - Check your config in `~/.clawdbot/clawdbot.json` - Or export the env var: `export VENICE_API_KEY="vn_..."` **"Invalid API key"** - Verify your key at [venice.ai/settings/api](https://venice.ai/settings/api) - Keys start with `vn_` **"Model not found"** - Run `--list-models` to see available models - Use `--no-validate` for new/beta models **Video stuck/timeout** - Videos can take 1-5 minutes depending on model and duration - Use `--timeout 600` for longer videos - Check Venice status at [venice.ai](https://venice.ai) **"requests" module not found** - Install it: `pip3 install requests`

path: scripts/venice_common.py
content: """Shared utilities for Venice AI scripts."""

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

CLAWDBOT_CONFIG_PATH = Path.home() / ".clawdbot" / "clawdbot.json"
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
API_BASE = "https://api.venice.ai/api/v1"


def get_api_key() -> str | None:
    """
    Get API key from multiple sources in order:
    1. VENICE_API_KEY environment variable
    2. ~/.clawdbot/clawdbot.json at skills.entries.venice-ai-media.env.VENICE_API_KEY
    """
    [REDACTED_SECRET]"VENICE_API_KEY", "").strip()
    if [REDACTED_SECRET] api_key

    if CLAWDBOT_CONFIG_PATH.exists():
        try:
            config = json.loads(CLAWDBOT_CONFIG_PATH.read_text(encoding="utf-8"))
            api_key = (
                config.get("skills", {})
                .get("entries", {})
                .get("venice-ai-media", {})
                .get("env", {})
                .get("VENICE_API_KEY", "")
            )
            if [REDACTED_SECRET] api_key.strip()
        except (json.JSONDecodeError, OSError):
            pass

    return None


def require_api_key() -> str:
    """Get API key or exit with error."""
    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"Error: VENICE_API_KEY not found", file=sys.stderr)
        print("Set VENICE_API_KEY env var or configure in ~/.clawdbot/clawdbot.json", file=sys.stderr)
        print("Get your API key at: https://venice.ai/settings/api", file=sys.stderr)
        sys.exit(2)
    return api_key


def make_request(
    endpoint: str,
    method: str = "GET",
    data: bytes | None = None,
    headers: dict | None = None,
    timeout: int = 120,
) -> bytes:
    """Make HTTP request to Venice API with proper headers."""
    url = f"{API_BASE}/{endpoint.lstrip('/')}"
    
    req_headers = {
        "User-Agent": USER_AGENT,
    }
    if headers:
        req_headers.update(headers)

    req = urllib.request.Request(url, method=method, headers=req_headers, data=data)

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.read()
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def api_json(
    endpoint: str,
    method: str = "GET",
    payload: dict | None = None,
    api_key: str | None = None,
    timeout: int = 120,
) -> dict:
    """Make JSON request to Venice API."""
    headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
    
    if payload:
        headers["Content-Type"] = "application/json"
        data = json.dumps(payload).encode("utf-8")
    else:
        data = None

    resp = make_request(endpoint, method, data, headers, timeout)
    return json.loads(resp.decode("utf-8"))


def list_models(api_key: str, model_type: str = "image") -> list[dict]:
    """Fetch available models from Venice API."""
    data = api_json(f"/models?type={model_type}", [REDACTED_SECRET], timeout=30)
    return data.get("data", [])


def print_models(models: list[dict]) -> None:
    """Print models in a formatted table with detailed info."""
    if not models:
        print("No models found.")
        return

    print(f"\n{'Model ID':<35} {'Status':<12} {'Info'}")
    print("-" * 100)
    for m in models:
        model_id = m.get("id", "unknown")
        model_type = m.get("type", "")
        spec = m.get("model_spec", {})
        
        # Determine status from model_spec fields
        if spec.get("offline", False):
            status = "offline"
        elif spec.get("beta", False):
            status = "beta"
        else:
            status = "available"
        
        # Check for deprecation warning
        deprecation = spec.get("deprecation", {})
        if deprecation.get("date"):
            status = "deprecated"
        
        # Build info string based on model type
        info_parts = []
        
        # Description
        desc = spec.get("description", "")
        if desc:
            info_parts.append(desc[:40])
        
        # Model-specific details
        constraints = spec.get("constraints", {})
        
        if model_type == "image":
            # Show resolutions if available
            resolutions = constraints.get("resolutions", [])
            if resolutions:
                info_parts.append(f"res: {'/'.join(resolutions)}")
        
        elif model_type == "video":
            # Show durations and type
            durations = constraints.get("durations", [])
            video_type = constraints.get("model_type", "")
            if video_type:
                info_parts.append(video_type)
            if durations:
                info_parts.append(f"dur: {'/'.join(durations)}")
        
        # Deprecation notice
        if deprecation.get("date"):
            info_parts.insert(0, f"[EOL {deprecation['date'][:10]}]")
        
        info = " | ".join(info_parts) if info_parts else ""
        print(f"{model_id:<35} {status:<12} {info[:55]}")
    
    print()
    print(f"Total: {len(models)} models")


def validate_model(api_key: str, model: str, model_type: str = "image") -> tuple[bool, list[str]]:
    """Check if model exists and is available. Returns (exists, list_of_available_models)."""
    try:
        models = list_models(api_key, model_type)
        # Model is available if it exists and is not offline
        available = [
            m.get("id") for m in models 
            if not m.get("model_spec", {}).get("offline", False)
        ]
        return model in available, available
    except RuntimeError:
        return True, []


def print_media_line(filepath: Path) -> None:
    """Print MEDIA: line for Clawdbot auto-attach."""
    print(f"\nMEDIA: {filepath.as_posix()}")


def get_mime_type(filename: str) -> str:
    """Get MIME type from filename."""
    suffix = Path(filename).suffix.lower()
    return {
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".png": "image/png",
        ".webp": "image/webp",
        ".gif": "image/gif",
        ".bmp": "image/bmp",
        ".tiff": "image/tiff",
        ".tif": "image/tiff",
    }.get(suffix, "application/octet-stream")


def default_out_dir(prefix: str = "venice") -> Path:
    """Create timestamped output directory."""
    now = dt.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
    base = Path("./tmp")
    base.mkdir(parents=True, exist_ok=True)
    return base / f"{prefix}-{now}"


def file_to_data_url(filepath: Path) -> str:
    """Convert local file to data URL."""
    suffix = filepath.suffix.lower()
    mime_types = {
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".png": "image/png",
        ".webp": "image/webp",
        ".gif": "image/gif",
        ".mp4": "video/mp4",
        ".mov": "video/quicktime",
        ".webm": "video/webm",
        ".wav": "audio/wav",
        ".mp3": "audio/mpeg",
    }
    mime = mime_types.get(suffix, "application/octet-stream")
    data = filepath.read_bytes()
    b64 = base64.b64encode(data).decode("ascii")
    return f"data:{mime};base64,{b64}"


path: scripts/venice-upscale.py
content: #!/usr/bin/env python3
"""Upscale images via Venice AI Image Upscale API."""

import argparse
import base64
import datetime as dt
import io
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path

# Import shared utilities
sys.path.insert(0, str(Path(__file__).parent))
from venice_common import require_api_key, print_media_line, get_mime_type, USER_AGENT, API_BASE


def upscale_image_from_file(
    api_key: str,
    image_path: Path,
    scale: int = 2,
    enhance: bool = False,
    enhance_prompt: str | None = None,
    enhance_creativity: float | None = None,
    replication: float | None = None,
) -> bytes:
    """
    Upscale an image via Venice API using multipart file upload.
    Returns raw image bytes.
    """
    url = f"{API_BASE}/image/upscale"
    
    # Build multipart form data
    boundary = "----VeniceUpscaleBoundary"
    
    parts = []
    
    # Add image file
    image_data = image_path.read_bytes()
    filename = image_path.name
    mime = get_mime_type(filename)
    
    parts.append(
        f'--{boundary}\r\n'
        f'Content-Disposition: form-data; name="image"; filename="{filename}"\r\n'
        f'Content-Type: {mime}\r\n\r\n'
    )
    
    # Add scale parameter
    parts.append(
        f'--{boundary}\r\n'
        f'Content-Disposition: form-data; name="scale"\r\n\r\n'
        f'{scale}\r\n'
    )
    
    # Add enhance parameter
    parts.append(
        f'--{boundary}\r\n'
        f'Content-Disposition: form-data; name="enhance"\r\n\r\n'
        f'{"true" if enhance else "false"}\r\n'
    )
    
    # Optional parameters
    if enhance_prompt:
        parts.append(
            f'--{boundary}\r\n'
            f'Content-Disposition: form-data; name="enhancePrompt"\r\n\r\n'
            f'{enhance_prompt}\r\n'
        )
    
    if enhance_creativity is not None:
        parts.append(
            f'--{boundary}\r\n'
            f'Content-Disposition: form-data; name="enhanceCreativity"\r\n\r\n'
            f'{enhance_creativity}\r\n'
        )
    
    if replication is not None:
        parts.append(
            f'--{boundary}\r\n'
            f'Content-Disposition: form-data; name="replication"\r\n\r\n'
            f'{replication}\r\n'
        )
    
    # Build body
    body = io.BytesIO()
    body.write(parts[0].encode())
    body.write(image_data)
    body.write(b'\r\n')
    for part in parts[1:]:
        body.write(part.encode())
    body.write(f'--{boundary}--\r\n'.encode())
    
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"multipart/form-data; boundary={boundary}",
            "User-Agent": USER_AGENT,
        },
        data=body.getvalue(),
    )
    
    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            return resp.read()
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def _fetch_url_as_base64(url: str) -> str:
    """Download an HTTP(S) URL and return as raw base64 string."""
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(req, timeout=60) as resp:
        data = resp.read()
        return base64.b64encode(data).decode("ascii")


def upscale_image_from_url(
    api_key: str,
    image_url: str,
    scale: int = 2,
    enhance: bool = False,
    enhance_prompt: str | None = None,
    enhance_creativity: float | None = None,
    replication: float | None = None,
) -> bytes:
    """
    Upscale an image via Venice API using a URL or base64 data URL.
    HTTP(S) URLs are downloaded and converted to base64 first.
    Returns raw image bytes.
    """
    
    # API requires base64, not HTTP URLs
    if image_url.startswith(("http://", "https://")):
        image_url = _fetch_url_as_base64(image_url)
    elif image_url.startswith("data:"):
        # Extract base64 from data URL
        if ";base64," in image_url:
            image_url = image_url.split(";base64,", 1)[1]
    
    url = f"{API_BASE}/image/upscale"
    
    payload: dict = {
        "image": image_url,
        "scale": scale,
        "enhance": enhance,
    }
    
    if enhance_prompt:
        payload["enhancePrompt"] = enhance_prompt
    if enhance_creativity is not None:
        payload["enhanceCreativity"] = enhance_creativity
    if replication is not None:
        payload["replication"] = replication
    
    body = json.dumps(payload).encode("utf-8")
    
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "User-Agent": USER_AGENT,
        },
        data=body,
    )
    
    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            return resp.read()
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def main() -> int:
    ap = argparse.ArgumentParser(description="Upscale images via Venice AI API.")
    ap.add_argument("image", nargs="?", help="Path to image file to upscale (or use --url)")
    ap.add_argument("--url", help="URL of image to upscale (http:// or https://)")
    ap.add_argument("--scale", type=int, default=2, choices=[1, 2, 3, 4],
                    help="Upscale factor 1-4 (default: 2)")
    ap.add_argument("--enhance", action="store_true",
                    help="Apply AI enhancement during upscale")
    ap.add_argument("--enhance-prompt", help="Description for enhancement (requires --enhance)")
    ap.add_argument("--enhance-creativity", type=float,
                    help="Creativity level 0.0-1.0 (requires --enhance)")
    ap.add_argument("--replication", type=float,
                    help="Replication factor 0.0-1.0 (preserves lines/noise, API default: 0.35)")
    ap.add_argument("--out-dir", help="Output directory (default: same as input or current dir for URLs)")
    ap.add_argument("--output", "-o", help="Output filename (default: auto-generated)")
    args = ap.parse_args()

    # Validate input
    if not args.image and not args.url:
        print("Error: Either image path or --url is required", file=sys.stderr)
        return 2
    if args.image and args.url:
        print("Error: Provide either image path or --url, not both", file=sys.stderr)
        return 2

    [REDACTED_SECRET])
    
    # Handle URL input
    if args.url:
        if not args.url.startswith(("http://", "https://")):
            print("Error: URL must start with http:// or https://", file=sys.stderr)
            return 2
        
        # Determine output path for URL input
        if args.output:
            out_path = Path(args.output).expanduser()
        else:
            out_dir = Path(args.out_dir).expanduser() if args.out_dir else Path.cwd()
            out_dir.mkdir(parents=True, exist_ok=True)
            timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
            out_path = out_dir / f"upscaled-{args.scale}x-{timestamp}.png"
        
        out_path.parent.mkdir(parents=True, exist_ok=True)
        
        print(f"Upscaling: {args.url[:60]}{'...' if len(args.url) > 60 else ''}")
        print(f"  Scale: {args.scale}x")
        print(f"  Enhance: {args.enhance}")
        
        try:
            result = upscale_image_from_url(
                [REDACTED_SECRET],
                image_url=args.url,
                scale=args.scale,
                enhance=args.enhance,
                enhance_prompt=args.enhance_prompt,
                enhance_creativity=args.enhance_creativity,
                replication=args.replication,
            )
        except RuntimeError as e:
            print(f"Error: {e}", file=sys.stderr)
            return 1
    else:
        # Handle file input
        image_path = Path(args.image).expanduser()
        if not image_path.exists():
            print(f"Error: Image not found: {image_path}", file=sys.stderr)
            return 2
        
        # Determine output path
        if args.output:
            out_path = Path(args.output).expanduser()
        else:
            out_dir = Path(args.out_dir).expanduser() if args.out_dir else image_path.parent
            out_dir.mkdir(parents=True, exist_ok=True)
            timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
            suffix = image_path.suffix or ".png"
            out_path = out_dir / f"{image_path.stem}-upscaled-{args.scale}x-{timestamp}{suffix}"
        
        out_path.parent.mkdir(parents=True, exist_ok=True)
        
        print(f"Upscaling: {image_path.name}")
        print(f"  Scale: {args.scale}x")
        print(f"  Enhance: {args.enhance}")
        
        try:
            result = upscale_image_from_file(
                [REDACTED_SECRET],
                image_path=image_path,
                scale=args.scale,
                enhance=args.enhance,
                enhance_prompt=args.enhance_prompt,
                enhance_creativity=args.enhance_creativity,
                replication=args.replication,
            )
        except RuntimeError as e:
            print(f"Error: {e}", file=sys.stderr)
            return 1
    
    out_path.write_bytes(result)
    print(f"\nSaved: {out_path.as_posix()}")
    print(f"Size: {len(result) / 1024:.1f}KB")
    
    print_media_line(out_path)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/venice-edit.py
content: #!/usr/bin/env python3
"""Edit images via Venice AI Image Edit API."""

import argparse
import datetime as dt
import io
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path

# Import shared utilities
sys.path.insert(0, str(Path(__file__).parent))
from venice_common import require_api_key, print_media_line, get_mime_type, USER_AGENT, API_BASE


def edit_image_from_file(
    api_key: str,
    image_path: Path,
    prompt: str,
) -> bytes:
    """
    Edit an image via Venice API using multipart file upload.
    Returns raw image bytes.
    """
    url = f"{API_BASE}/image/edit"
    
    # Build multipart form data
    boundary = "----VeniceEditBoundary"
    
    # Read image
    image_data = image_path.read_bytes()
    filename = image_path.name
    mime = get_mime_type(filename)
    
    body = io.BytesIO()
    
    # Add image file
    body.write(f'--{boundary}\r\n'.encode())
    body.write(f'Content-Disposition: form-data; name="image"; filename="{filename}"\r\n'.encode())
    body.write(f'Content-Type: {mime}\r\n\r\n'.encode())
    body.write(image_data)
    body.write(b'\r\n')
    
    # Add prompt
    body.write(f'--{boundary}\r\n'.encode())
    body.write(b'Content-Disposition: form-data; name="prompt"\r\n\r\n')
    body.write(prompt.encode())
    body.write(b'\r\n')
    
    # End boundary
    body.write(f'--{boundary}--\r\n'.encode())
    
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"multipart/form-data; boundary={boundary}",
            "User-Agent": USER_AGENT,
        },
        data=body.getvalue(),
    )
    
    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            return resp.read()
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def edit_image_from_url(
    api_key: str,
    image_url: str,
    prompt: str,
) -> bytes:
    """
    Edit an image via Venice API using a URL.
    Returns raw image bytes.
    """
    
    url = f"{API_BASE}/image/edit"
    
    payload = {
        "image": image_url,
        "prompt": prompt,
    }
    
    body = json.dumps(payload).encode("utf-8")
    
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "User-Agent": USER_AGENT,
        },
        data=body,
    )
    
    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            return resp.read()
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def main() -> int:
    ap = argparse.ArgumentParser(
        description="Edit images via Venice AI API. The AI interprets your prompt to determine what to modify."
    )
    ap.add_argument("image", nargs="?", help="Path to image file to edit (or use --url)")
    ap.add_argument("--url", help="URL of image to edit (http:// or https://)")
    ap.add_argument("--prompt", "-p", required=True, help="Edit instructions (e.g., 'add sunglasses', 'change sky to sunset')")
    ap.add_argument("--out-dir", help="Output directory (default: same as input or current dir for URLs)")
    ap.add_argument("--output", "-o", help="Output filename (default: auto-generated)")
    args = ap.parse_args()

    # Validate input
    if not args.image and not args.url:
        print("Error: Either image path or --url is required", file=sys.stderr)
        return 2
    if args.image and args.url:
        print("Error: Provide either image path or --url, not both", file=sys.stderr)
        return 2

    [REDACTED_SECRET])
    
    # Handle URL input
    if args.url:
        if not args.url.startswith(("http://", "https://")):
            print("Error: URL must start with http:// or https://", file=sys.stderr)
            return 2
        
        # Determine output path for URL input
        if args.output:
            out_path = Path(args.output).expanduser()
        else:
            out_dir = Path(args.out_dir).expanduser() if args.out_dir else Path.cwd()
            out_dir.mkdir(parents=True, exist_ok=True)
            timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
            out_path = out_dir / f"edited-{timestamp}.png"
        
        out_path.parent.mkdir(parents=True, exist_ok=True)
        
        print(f"Editing: {args.url[:60]}{'...' if len(args.url) > 60 else ''}")
        print(f"  Prompt: {args.prompt[:80]}{'...' if len(args.prompt) > 80 else ''}")
        
        try:
            result = edit_image_from_url(
                [REDACTED_SECRET],
                image_url=args.url,
                prompt=args.prompt,
            )
        except RuntimeError as e:
            print(f"Error: {e}", file=sys.stderr)
            return 1
    else:
        # Handle file input
        image_path = Path(args.image).expanduser()
        if not image_path.exists():
            print(f"Error: Image not found: {image_path}", file=sys.stderr)
            return 2
        
        # Determine output path
        if args.output:
            out_path = Path(args.output).expanduser()
        else:
            out_dir = Path(args.out_dir).expanduser() if args.out_dir else image_path.parent
            out_dir.mkdir(parents=True, exist_ok=True)
            timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
            suffix = image_path.suffix or ".png"
            out_path = out_dir / f"{image_path.stem}-edited-{timestamp}{suffix}"
        
        out_path.parent.mkdir(parents=True, exist_ok=True)
        
        print(f"Editing: {image_path.name}")
        print(f"  Prompt: {args.prompt[:80]}{'...' if len(args.prompt) > 80 else ''}")
        
        try:
            result = edit_image_from_file(
                [REDACTED_SECRET],
                image_path=image_path,
                prompt=args.prompt,
            )
        except RuntimeError as e:
            print(f"Error: {e}", file=sys.stderr)
            return 1
    
    out_path.write_bytes(result)
    print(f"\nSaved: {out_path.as_posix()}")
    print(f"Size: {len(result) / 1024:.1f}KB")
    
    print_media_line(out_path)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/venice-video.py
content: #!/usr/bin/env python3
"""Generate videos via Venice AI Video API (queue + retrieve)."""

import argparse
import datetime as dt
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

# Import shared utilities
sys.path.insert(0, str(Path(__file__).parent))
from venice_common import (
    require_api_key,
    list_models,
    print_models,
    validate_model,
    print_media_line,
    default_out_dir,
    file_to_data_url,
    USER_AGENT,
)

DEFAULT_MODEL = "wan-2.6-image-to-video"


def resolve_media_url(path_or_url: str) -> str:
    """Convert local path to data URL or return URL as-is."""
    if path_or_url.startswith(("http://", "https://", "data:")):
        return path_or_url

    filepath = Path(path_or_url).expanduser()
    if not filepath.exists():
        raise FileNotFoundError(f"File not found: {filepath}")

    return file_to_data_url(filepath)


def get_video_quote(
    api_key: str,
    model: str,
    duration: str,
    resolution: str,
    aspect_ratio: str | None,
    audio: bool | None,
) -> dict:
    """Get a price quote for video generation."""
    url = "https://api.venice.ai/api/v1/video/quote"

    payload: dict = {
        "model": model,
        "duration": duration,
        "resolution": resolution,
    }
    
    if aspect_ratio:
        payload["aspect_ratio"] = aspect_ratio
    if audio is not None:
        payload["audio"] = audio

    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "User-Agent": USER_AGENT,
        },
        data=body,
    )

    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def queue_video(
    api_key: str,
    model: str,
    prompt: str,
    duration: str,
    image_url: str | None,
    negative_prompt: str | None,
    aspect_ratio: str | None,
    resolution: str,
    audio: bool | None,
    audio_url: str | None,
) -> dict:
    """Queue a video generation request."""
    url = "https://api.venice.ai/api/v1/video/queue"

    payload: dict = {
        "model": model,
        "prompt": prompt,
        "duration": duration,
        "resolution": resolution,
    }
    
    # Only include audio param if explicitly set (some models don't support it)
    if audio is not None:
        payload["audio"] = audio

    if image_url:
        payload["image_url"] = image_url
    if negative_prompt:
        payload["negative_prompt"] = negative_prompt
    if aspect_ratio:
        payload["aspect_ratio"] = aspect_ratio
    if audio_url:
        payload["audio_url"] = audio_url

    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "User-Agent": USER_AGENT,
        },
        data=body,
    )

    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def retrieve_video(
    api_key: str,
    model: str,
    queue_id: str,
    delete_on_completion: bool = True,
) -> tuple[str, bytes | None, dict]:
    """
    Check video status and retrieve if complete.
    Returns (status, video_bytes or None, timing_info).
    timing_info contains average_execution_time and execution_duration when available.
    """
    url = "https://api.venice.ai/api/v1/video/retrieve"

    payload = {
        "model": model,
        "queue_id": queue_id,
        "delete_media_on_completion": delete_on_completion,
    }

    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "User-Agent": USER_AGENT,
        },
        data=body,
    )

    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            content_type = resp.headers.get("Content-Type", "")

            if "video/" in content_type:
                # Video is ready
                return "COMPLETED", resp.read(), {}

            # Still processing - JSON response
            data = json.loads(resp.read().decode("utf-8"))
            status = data.get("status", "UNKNOWN")
            timing_info = {
                "average_execution_time": data.get("average_execution_time"),
                "execution_duration": data.get("execution_duration"),
            }
            return status, None, timing_info

    except urllib.error.HTTPError as e:
        if e.code == 404:
            return "NOT_FOUND", None, {}
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def complete_video(api_key: str, model: str, queue_id: str) -> bool:
    """
    Mark a video as completed and delete from server storage.
    Use this to clean up videos downloaded with --no-delete.
    Returns True on success.
    """
    url = "https://api.venice.ai/api/v1/video/complete"

    payload = {
        "model": model,
        "queue_id": queue_id,
    }

    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "User-Agent": USER_AGENT,
        },
        data=body,
    )

    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return data.get("success", False)
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def main() -> int:
    ap = argparse.ArgumentParser(description="Generate videos via Venice AI API.")
    ap.add_argument("--image", help="Source image (local path or URL)")
    ap.add_argument("--prompt", help="Video description (1-2500 chars)")
    ap.add_argument("--model", default=DEFAULT_MODEL, help=f"Model ID (default: {DEFAULT_MODEL})")
    ap.add_argument("--duration", default="5s", help="Video duration (model-dependent, use --list-models to see options per model)")
    ap.add_argument("--resolution", default="720p", choices=["480p", "720p", "1080p"], help="Output resolution (default: 720p)")
    ap.add_argument("--aspect-ratio", help="Video aspect ratio (e.g., 16:9)")
    ap.add_argument("--audio", action="store_true", default=None, help="Generate audio track")
    ap.add_argument("--no-audio", action="store_false", dest="audio", help="Disable audio generation")
    ap.add_argument("--skip-audio-param", action="store_true", help="Don't send audio param (for models that don't support it)")
    ap.add_argument("--audio-url", help="Background music file (WAV/MP3, max 30s, 15MB)")
    ap.add_argument("--negative-prompt", help="What to avoid in the video")
    ap.add_argument("--out-dir", help="Output directory (default: auto-generated)")
    ap.add_argument("--poll-interval", type=int, default=10, help="Status check interval in seconds (default: 10)")
    ap.add_argument("--timeout", type=int, default=600, help="Max wait time in seconds (default: 600)")
    ap.add_argument("--no-delete", action="store_true", help="Don't delete server media after download")
    ap.add_argument("--complete", metavar="QUEUE_ID", help="Clean up a previously downloaded video (use with --model)")
    ap.add_argument("--list-models", action="store_true", help="List available video models and exit")
    ap.add_argument("--quote", action="store_true", help="Show price estimate and exit (no generation)")
    ap.add_argument("--no-validate", action="store_true", help="Skip model validation")
    args = ap.parse_args()

    [REDACTED_SECRET])

    # Handle --complete (cleanup)
    if args.complete:
        try:
            success = complete_video(api_key, args.model, args.complete)
            if success:
                print(f"Video {args.complete} cleaned up successfully")
                return 0
            else:
                print(f"Failed to clean up video {args.complete}", file=sys.stderr)
                return 1
        except RuntimeError as e:
            print(f"Error: {e}", file=sys.stderr)
            return 1

    # Handle --list-models
    if args.list_models:
        try:
            models = list_models(api_key, "video")
            print_models(models)
            return 0
        except RuntimeError as e:
            print(f"Error: {e}", file=sys.stderr)
            return 1

    # Handle --quote (price estimate)
    if args.quote:
        # Determine audio setting for quote
        audio_param = None if args.skip_audio_param else (args.audio if args.audio is not None else True)
        
        try:
            quote = get_video_quote(
                [REDACTED_SECRET],
                model=args.model,
                duration=args.duration,
                resolution=args.resolution,
                aspect_ratio=args.aspect_ratio,
                audio=audio_param,
            )
            price = quote.get("quote", 0)
            print(f"\nVideo Generation Price Quote")
            print(f"  Model: {args.model}")
            print(f"  Duration: {args.duration}")
            print(f"  Resolution: {args.resolution}")
            if args.aspect_ratio:
                print(f"  Aspect ratio: {args.aspect_ratio}")
            print(f"  Audio: {audio_param if audio_param is not None else '(default)'}")
            print(f"\n  Estimated cost: ${price:.4f} USD")
            return 0
        except RuntimeError as e:
            print(f"Error getting quote: {e}", file=sys.stderr)
            return 1

    # Validate input (only if not listing models or getting quote)
    if not args.prompt:
        print("Error: --prompt is required", file=sys.stderr)
        return 2

    if not args.image:
        print("Error: --image is required", file=sys.stderr)
        return 2

    # Validate model if not skipped
    if not args.no_validate:
        exists, available = validate_model(api_key, args.model, "video")
        if not exists and available:
            print(f"Error: Model '{args.model}' not found or unavailable.", file=sys.stderr)
            print(f"Available video models: {', '.join(available)}", file=sys.stderr)
            return 2

    out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir("venice-video")
    out_dir.mkdir(parents=True, exist_ok=True)

    # Resolve media URLs
    image_url = None
    audio_url = None

    try:
        if args.image:
            print(f"Loading image: {args.image}", flush=True)
            image_url = resolve_media_url(args.image)
            if image_url.startswith("data:"):
                print(f"  Encoded as data URL ({len(image_url) // 1024}KB)")

        if args.audio_url:
            print(f"Loading audio: {args.audio_url}", flush=True)
            audio_url = resolve_media_url(args.audio_url)
            if audio_url.startswith("data:"):
                print(f"  Encoded as data URL ({len(audio_url) // 1024}KB)")

    except FileNotFoundError as e:
        print(f"Error: {e}", file=sys.stderr)
        return 2

    # Determine audio setting
    audio_param = None if args.skip_audio_param else (args.audio if args.audio is not None else True)
    
    # Queue video generation
    print(f"\nQueuing video generation...", flush=True)
    print(f"  Model: {args.model}")
    print(f"  Duration: {args.duration}")
    print(f"  Resolution: {args.resolution}")
    print(f"  Audio: {audio_param if audio_param is not None else '(skipped)'}")
    print(f"  Prompt: {args.prompt[:100]}{'...' if len(args.prompt) > 100 else ''}")

    try:
        queue_result = queue_video(
            [REDACTED_SECRET],
            model=args.model,
            prompt=args.prompt,
            duration=args.duration,
            image_url=image_url,
            negative_prompt=args.negative_prompt,
            aspect_ratio=args.aspect_ratio,
            resolution=args.resolution,
            audio=audio_param,
            audio_url=audio_url,
        )
    except RuntimeError as e:
        print(f"\nError queuing video: {e}", file=sys.stderr)
        return 1

    queue_id = queue_result.get("queue_id")
    if not queue_id:
        print(f"Error: No queue_id in response: {queue_result}", file=sys.stderr)
        return 1

    print(f"\nQueued successfully!", flush=True)
    print(f"  Queue ID: {queue_id}")

    # Poll for completion
    print(f"\nWaiting for video generation (polling every {args.poll_interval}s, timeout {args.timeout}s)...")

    start_time = time.time()
    last_status = None
    last_timing_shown = None

    while True:
        elapsed = time.time() - start_time
        if elapsed > args.timeout:
            print(f"\nTimeout after {args.timeout}s", file=sys.stderr)
            return 1

        try:
            status, video_data, timing_info = retrieve_video(
                [REDACTED_SECRET],
                model=args.model,
                queue_id=queue_id,
                delete_on_completion=not args.no_delete,
            )
        except RuntimeError as e:
            print(f"\nError retrieving video: {e}", file=sys.stderr)
            return 1

        # Build status message with timing info
        timing_str = ""
        avg_time = timing_info.get("average_execution_time")
        exec_dur = timing_info.get("execution_duration")
        if avg_time and exec_dur:
            progress_pct = min(100, int((exec_dur / avg_time) * 100))
            timing_str = f" ({exec_dur // 1000}s / ~{avg_time // 1000}s avg, ~{progress_pct}%)"
        elif exec_dur:
            timing_str = f" ({exec_dur // 1000}s elapsed)"

        status_msg = f"{status}{timing_str}"
        if status != last_status or timing_str != last_timing_shown:
            print(f"  [{int(elapsed)}s] Status: {status_msg}")
            last_status = status
            last_timing_shown = timing_str

        if status == "COMPLETED" and video_data:
            # Save video
            timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
            filename = f"video-{timestamp}.mp4"
            filepath = out_dir / filename
            filepath.write_bytes(video_data)

            # Save metadata
            metadata = {
                "queue_id": queue_id,
                "model": args.model,
                "prompt": args.prompt,
                "duration": args.duration,
                "resolution": args.resolution,
                "audio": audio_param,
                "source_image": args.image,
                "generated_at": dt.datetime.now().isoformat(),
            }
            (out_dir / "metadata.json").write_text(
                json.dumps(metadata, indent=2), encoding="utf-8"
            )

            print(f"\nVideo saved: {filepath.as_posix()}")
            print(f"Size: {len(video_data) / 1024 / 1024:.1f}MB")
            
            # Print MEDIA: line for Clawdbot auto-attach
            print_media_line(filepath)
            return 0

        if status == "NOT_FOUND":
            print(f"\nError: Video not found (may have expired)", file=sys.stderr)
            return 1

        if status in ("FAILED", "ERROR", "CANCELLED"):
            print(f"\nVideo generation failed: {status}", file=sys.stderr)
            return 1

        if status not in ("PROCESSING", "QUEUED", "PENDING"):
            print(f"\nUnexpected status: {status}", file=sys.stderr)

        time.sleep(args.poll_interval)


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/venice-image.py
content: #!/usr/bin/env python3
"""Generate images via Venice AI Image API."""

import argparse
import base64
import json
import random
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path

# Import shared utilities
sys.path.insert(0, str(Path(__file__).parent))
from venice_common import (
    require_api_key,
    list_models,
    print_models,
    validate_model,
    print_media_line,
    default_out_dir,
    USER_AGENT,
)

DEFAULT_MODEL = "flux-2-max"


def slugify(text: str, max_len: int = 40) -> str:
    """Convert text to URL-safe slug."""
    text = text.lower().strip()
    text = re.sub(r"[^a-z0-9]+", "-", text)
    text = re.sub(r"-{2,}", "-", text).strip("-")
    return (text or "image")[:max_len]


def pick_prompts(count: int) -> list[str]:
    """Generate random creative prompts."""
    subjects = [
        "a Venetian canal at golden hour",
        "a cyberpunk market street",
        "a minimalist sculpture",
        "an ancient library interior",
        "a bioluminescent forest",
        "a steampunk airship",
        "a serene Japanese garden",
    ]
    styles = [
        "cinematic photography",
        "oil painting style",
        "architectural visualization",
        "editorial photo",
        "concept art",
        "hyperrealistic render",
        "impressionist painting",
    ]
    moods = [
        "dramatic lighting",
        "soft morning light",
        "neon glow",
        "foggy atmosphere",
        "warm sunset tones",
        "cool blue hour",
    ]
    return [
        f"{random.choice(styles)} of {random.choice(subjects)}, {random.choice(moods)}"
        for _ in range(count)
    ]


def list_styles(api_key: str) -> list[str]:
    """Fetch available image styles from Venice API."""
    url = "https://api.venice.ai/api/v1/image/styles"
    req = urllib.request.Request(
        url,
        method="GET",
        headers={
            "Authorization": f"Bearer {api_key}",
            "User-Agent": USER_AGENT,
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return data.get("data", [])
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def generate_image(
    api_key: str,
    prompt: str,
    model: str,
    width: int,
    height: int,
    fmt: str,
    cfg_scale: float | None,
    seed: int | None,
    negative_prompt: str | None,
    style_preset: str | None,
    resolution: str | None,
    aspect_ratio: str | None,
    safe_mode: bool,
    hide_watermark: bool,
    variants: int = 1,
    embed_exif: bool = False,
    lora_strength: int | None = None,
    enable_web_search: bool = False,
    steps: int | None = None,
) -> dict:
    """Call Venice Image Generate API.
    
    Args:
        variants: Number of images to generate (1-4). More efficient than multiple calls.
        embed_exif: Embed prompt generation info in EXIF metadata.
        lora_strength: LoRA strength 0-100 for applicable models.
        enable_web_search: Enable web search for image generation.
        steps: Number of inference steps (model-dependent).
    """
    url = "https://api.venice.ai/api/v1/image/generate"

    payload: dict = {
        "model": model,
        "prompt": prompt,
        "width": width,
        "height": height,
        "format": fmt,
        "safe_mode": safe_mode,
        "hide_watermark": hide_watermark,
    }

    # Use variants for efficient batch generation (API supports 1-4)
    if variants > 1:
        payload["variants"] = min(variants, 4)

    if cfg_scale is not None:
        payload["cfg_scale"] = cfg_scale
    if seed is not None:
        payload["seed"] = seed
    if negative_prompt:
        payload["negative_prompt"] = negative_prompt
    if style_preset:
        payload["style_preset"] = style_preset
    if resolution:
        payload["resolution"] = resolution
    if aspect_ratio:
        payload["aspect_ratio"] = aspect_ratio
    if embed_exif:
        payload["embed_exif_metadata"] = True
    if lora_strength is not None:
        payload["lora_strength"] = lora_strength
    if enable_web_search:
        payload["enable_web_search"] = True
    if steps is not None:
        payload["steps"] = steps

    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        url,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "User-Agent": USER_AGENT,
        },
        data=body,
    )

    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Venice API error ({e.code}): {error_body}") from e


def write_gallery(out_dir: Path, items: list[dict]) -> None:
    """Generate HTML thumbnail gallery."""
    thumbs = "\n".join(
        f"""<figure>
  <a href="{it['file']}"><img src="{it['file']}" loading="lazy" /></a>
  <figcaption>{it['prompt'][:100]}{'...' if len(it['prompt']) > 100 else ''}</figcaption>
</figure>"""
        for it in items
    )

    html = f"""<!doctype html>
<meta charset="utf-8" />
<title>Venice Image Gallery</title>
<style>
  :root {{ color-scheme: dark; }}
  body {{ margin: 24px; font: 14px/1.4 ui-sans-serif, system-ui; background: #0b0f14; color: #e8edf2; }}
  h1 {{ font-size: 18px; margin: 0 0 16px; }}
  .grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }}
  figure {{ margin: 0; padding: 12px; border: 1px solid #1e2a36; border-radius: 14px; background: #0f1620; }}
  img {{ width: 100%; height: auto; border-radius: 10px; display: block; }}
  figcaption {{ margin-top: 10px; color: #b7c2cc; font-size: 13px; }}
  code {{ color: #9cd1ff; }}
</style>
<h1>Venice Image Gallery</h1>
<p>Output: <code>{out_dir.as_posix()}</code></p>
<div class="grid">
{thumbs}
</div>
"""
    (out_dir / "index.html").write_text(html, encoding="utf-8")


def main() -> int:
    ap = argparse.ArgumentParser(description="Generate images via Venice AI API.")
    ap.add_argument("--prompt", help="Image description. If omitted, generates random prompts.")
    ap.add_argument("--model", default=DEFAULT_MODEL, help=f"Model ID (default: {DEFAULT_MODEL})")
    ap.add_argument("--count", type=int, default=1, help="Number of images to generate (default: 1)")
    ap.add_argument("--width", type=int, default=1024, help="Image width 1-1280 (default: 1024)")
    ap.add_argument("--height", type=int, default=1024, help="Image height 1-1280 (default: 1024)")
    ap.add_argument("--format", dest="fmt", default="webp", choices=["jpeg", "png", "webp"], help="Output format (default: webp)")
    ap.add_argument("--cfg-scale", type=float, help="Prompt adherence 0-20 (default: 7.5)")
    ap.add_argument("--seed", type=int, help="Random seed for reproducibility")
    ap.add_argument("--negative-prompt", help="What to exclude from the image")
    ap.add_argument("--style-preset", help="Visual style preset")
    ap.add_argument("--resolution", help="Resolution preset (1K, 2K, 4K)")
    ap.add_argument("--aspect-ratio", help="Aspect ratio (1:1, 16:9, etc.)")
    ap.add_argument("--safe-mode", action="store_true", default=False, help="Blur adult content (default: false)")
    ap.add_argument("--no-safe-mode", action="store_false", dest="safe_mode", help="Disable safe mode")
    ap.add_argument("--hide-watermark", action="store_true", help="Remove Venice watermark")
    ap.add_argument("--embed-exif", action="store_true", help="Embed prompt info in image EXIF metadata")
    ap.add_argument("--lora-strength", type=int, help="LoRA strength 0-100 for applicable models")
    ap.add_argument("--enable-web-search", action="store_true", help="Enable web search for image generation")
    ap.add_argument("--steps", type=int, help="Inference steps (model-dependent)")
    ap.add_argument("--out-dir", help="Output directory (default: auto-generated)")
    ap.add_argument("--list-models", action="store_true", help="List available image models and exit")
    ap.add_argument("--list-styles", action="store_true", help="List available style presets and exit")
    ap.add_argument("--no-validate", action="store_true", help="Skip model validation")
    args = ap.parse_args()

    [REDACTED_SECRET])

    # Handle --list-models
    if args.list_models:
        try:
            models = list_models(api_key, "image")
            print_models(models)
            return 0
        except RuntimeError as e:
            print(f"Error: {e}", file=sys.stderr)
            return 1

    # Handle --list-styles
    if args.list_styles:
        try:
            styles = list_styles(api_key)
            print("\nAvailable Image Styles:")
            print("-" * 40)
            for style in styles:
                print(f"  {style}")
            print(f"\nTotal: {len(styles)} styles")
            print("\nUsage: --style-preset \"Style Name\"")
            return 0
        except RuntimeError as e:
            print(f"Error: {e}", file=sys.stderr)
            return 1

    # Validate model if not skipped
    if not args.no_validate:
        exists, available = validate_model(api_key, args.model, "image")
        if not exists and available:
            print(f"Error: Model '{args.model}' not found or unavailable.", file=sys.stderr)
            print(f"Available image models: {', '.join(available)}", file=sys.stderr)
            return 2

    out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir("venice-image")
    out_dir.mkdir(parents=True, exist_ok=True)

    items: list[dict] = []
    image_counter = 0
    
    # If using same prompt for multiple images, use variants for efficiency (up to 4 per request)
    if args.prompt:
        remaining = args.count
        batch_num = 0
        
        while remaining > 0:
            batch_num += 1
            variants = min(remaining, 4)  # API max is 4 variants per request
            
            print(f"[Batch {batch_num}] Generating {variants} image(s): {args.prompt[:60]}{'...' if len(args.prompt) > 60 else ''}")
            
            try:
                res = generate_image(
                    [REDACTED_SECRET],
                    prompt=args.prompt,
                    model=args.model,
                    width=args.width,
                    height=args.height,
                    fmt=args.fmt,
                    cfg_scale=args.cfg_scale,
                    seed=args.seed,
                    negative_prompt=args.negative_prompt,
                    style_preset=args.style_preset,
                    resolution=args.resolution,
                    aspect_ratio=args.aspect_ratio,
                    safe_mode=args.safe_mode,
                    hide_watermark=args.hide_watermark,
                    variants=variants,
                    embed_exif=args.embed_exif,
                    lora_strength=args.lora_strength,
                    enable_web_search=args.enable_web_search,
                    steps=args.steps,
                )
            except RuntimeError as e:
                print(f"  Error: {e}", file=sys.stderr)
                remaining -= variants
                continue

            images = res.get("images", [])
            if not images:
                print(f"  Warning: No images returned", file=sys.stderr)
                remaining -= variants
                continue

            for img_idx, image_b64 in enumerate(images):
                image_counter += 1
                filename = f"{image_counter:03d}-{slugify(args.prompt)}.{args.fmt}"
                filepath = out_dir / filename

                try:
                    filepath.write_bytes(base64.b64decode(image_b64))
                    print(f"  Saved: {filename}")
                    items.append({"prompt": args.prompt, "file": filename})
                except Exception as e:
                    print(f"  Error saving image: {e}", file=sys.stderr)
            
            remaining -= variants
    else:
        # Different prompts for each image - can't use variants
        prompts = pick_prompts(args.count)
        
        for idx, prompt in enumerate(prompts, start=1):
            print(f"[{idx}/{len(prompts)}] {prompt[:80]}{'...' if len(prompt) > 80 else ''}")

            try:
                res = generate_image(
                    [REDACTED_SECRET],
                    prompt=prompt,
                    model=args.model,
                    width=args.width,
                    height=args.height,
                    fmt=args.fmt,
                    cfg_scale=args.cfg_scale,
                    seed=args.seed,
                    negative_prompt=args.negative_prompt,
                    style_preset=args.style_preset,
                    resolution=args.resolution,
                    aspect_ratio=args.aspect_ratio,
                    safe_mode=args.safe_mode,
                    hide_watermark=args.hide_watermark,
                    embed_exif=args.embed_exif,
                    lora_strength=args.lora_strength,
                    enable_web_search=args.enable_web_search,
                    steps=args.steps,
                )
            except RuntimeError as e:
                print(f"  Error: {e}", file=sys.stderr)
                continue

            images = res.get("images", [])
            if not images:
                print(f"  Warning: No images returned", file=sys.stderr)
                continue

            for img_idx, image_b64 in enumerate(images):
                image_counter += 1
                suffix = f"-{img_idx + 1}" if len(images) > 1 else ""
                filename = f"{image_counter:03d}{suffix}-{slugify(prompt)}.{args.fmt}"
                filepath = out_dir / filename

                try:
                    filepath.write_bytes(base64.b64decode(image_b64))
                    print(f"  Saved: {filename}")
                    items.append({"prompt": prompt, "file": filename})
                except Exception as e:
                    print(f"  Error saving image: {e}", file=sys.stderr)

    if items:
        (out_dir / "prompts.json").write_text(
            json.dumps(items, indent=2), encoding="utf-8"
        )
        write_gallery(out_dir, items)
        print(f"\nGallery: {(out_dir / 'index.html').as_posix()}")
        
        # Print MEDIA: line for Clawdbot auto-attach (first image)
        first_file = out_dir / items[0]["file"]
        print_media_line(first_file)

    return 0 if items else 1


if __name__ == "__main__":
    raise SystemExit(main())


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