Back to skills
SkillHub ClubShip Full StackFull Stack

image-generation

图像生成辅助。支持通过 OpenRouter 直接调用各种生图模型(如 Seedream),为 OpenClaw 优化,支持提示词、尺寸等参数配置。目前仅限 OpenRouter provider。

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,111
Hot score
99
Updated
March 20, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
C62.8

Install command

npx @skill-hub/cli install openclaw-skills-phoenixclaw-image-gen

Repository

openclaw/skills

Skill path: skills/goforu/phoenixclaw-image-gen

图像生成辅助。支持通过 OpenRouter 直接调用各种生图模型(如 Seedream),为 OpenClaw 优化,支持提示词、尺寸等参数配置。目前仅限 OpenRouter provider。

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack.

Target audience: everyone.

License: MIT.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install image-generation into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding image-generation to shared team environments
  • Use image-generation for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: image-generation
description: 图像生成辅助。支持通过 OpenRouter 直接调用各种生图模型(如 Seedream),为 OpenClaw 优化,支持提示词、尺寸等参数配置。目前仅限 OpenRouter provider。
license: MIT
compatibility: OpenClaw, Claude Code, OpenCode, Antigravity
metadata:
  version: "0.2.1"
  openclaw:
    emoji: "🎨"
    requires:
      env: ["OPENROUTER_API_KEY"]
      optional: ["IMAGE_GEN_TEXT_TO_IMAGE_MODEL", "IMAGE_GEN_IMAGE_TO_IMAGE_MODEL"]
    primaryEnv: "OPENROUTER_API_KEY"
---

# 图像生成辅助

为 OpenClaw 提供原生的图像生成能力,通过文字描述词快速生成高质量图像。

## 工作流程

```
用户提示词 (Prompt)
    ↓
┌─────────────────────────────────────┐
│  识别参数:模型、尺寸、比例、路径   │
└─────────────────────────────────────┘
    ↓
┌─────────────────────────────────────┐
│  调用 OpenRouter 接口 (OpenClaw)    │
└─────────────────────────────────────┘
    ↓
┌─────────────────────────────────────┐
│  默认模型:seedream-4.5 (Text-to-Image) & Gemini 2.5 (Image-to-Image) │
└─────────────────────────────────────┘
    ↓
┌─────────────────────────────────────┐
│  原子化写入本地存储 (Safety First)  │
└─────────────────────────────────────┘
    ↓
返回结构化 JSON (Success Payload)
```

## 安装与配置

### 1. 获取 API Key

本技能需要 **OpenRouter API Key** 才能调用图像生成服务。

1. 访问 [OpenRouter 控制台](https://openrouter.ai/keys) 创建 API Key
2. 请根据下方指引手动配置环境变量

### 2. 配置到环境变量

根据 OpenClaw 官方最佳实践,请通过 `~/.openclaw/openclaw.json` 配置环境变量:

```json5
{
  skills: {
    entries: {
      "image-generation": {
        enabled: true,
        env: {
          OPENROUTER_API_KEY: "sk-or-v1-xxxxxxxx..."
        }
      }
    }
  }
}
```

或使用 `primaryEnv` 快捷配置:

```json5
{
  skills: {
    entries: {
      "image-generation": {
        enabled: true,
        apiKey: "sk-or-v1-xxxxxxxx..."
      }
    }
  }
}
```

配置完成后无需重启,新配置会在下次运行 OpenClaw 时自动生效。


### 3. 选择默认模型(可选)

本技能默认使用 `bytedance-seed/seedream-4.5` 作为文本生图模型,`google/gemini-2.5-flash-image` 作为图生图模型。你可以通过以下方式更改:

**方式一:环境变量配置**
在 `~/.openclaw/openclaw.json` 中添加:
```json5
{
  skills: {
    entries: {
      "image-generation": {
        enabled: true,
        env: {
          OPENROUTER_API_KEY: "sk-or-v1-xxx",
          IMAGE_GEN_TEXT_TO_IMAGE_MODEL: "bytedance-seed/seedream-4.5",
          IMAGE_GEN_IMAGE_TO_IMAGE_MODEL: "google/gemini-2.5-flash-image"
        }
      }
    }
  }
}
```


本技能默认使用 `bytedance-seed/seedream-4.5` 模型,你可以通过以下方式更改:

**方式一:环境变量配置**
在 `~/.openclaw/openclaw.json` 中添加:
```json5
{
  skills: {
    entries: {
      "image-generation": {
        enabled: true,
        env: {
          OPENROUTER_API_KEY: "sk-or-v1-xxx",
          IMAGE_GEN_IMAGE_TO_IMAGE_MODEL: "google/gemini-2.5-flash-image",
          IMAGE_GEN_TEXT_TO_IMAGE_MODEL: "bytedance-seed/seedream-4.5"
      }
    }
  }
}
```

**方式二:查看可用模型**
```bash
node skills/image-generation/scripts/cli/openrouter.js --list-models
```

**方式三:生成时指定模型**
```bash
node skills/image-generation/scripts/generate.js \
  --prompt "a futuristic city" \
  --model "bytedance-seed/seedream-4.5" \
  --i2i-model "google/gemini-2.5-flash-image"
```

```bash
node skills/image-generation/scripts/generate.js \
  --prompt "a futuristic city" \
  --image "bytedance-seed/seedream-4.5" \
  --model "google/gemini-2.5-flash-image"
```

**推荐模型**:
- `bytedance-seed/seedream-4.5` - 默认,高质量图像生成
- `anthropic/claude-3.5-sonnet-image` - Claude 图像生成
- `openai/dall-e-3` - DALL-E 3

### 4. 验证配置

```bash
node skills/image-generation/scripts/cli/openrouter.js --test
```

预期输出:
```json
{
  "success": true,
  "message": "OpenRouter API key is configured. Test passed."
}
```

## 核心配置

### 仅限 OpenRouter (v1)

本技能在 v1 版本中**仅支持**通过 OpenRouter 提供商进行图像生成。目前不原生支持 Anthropic、Replicate 或 Stability AI 等直接接口。所有生图请求均通过 OpenRouter 统一中转。

### 默认模型

- **文本生图模型 (Text-to-Image)**: `bytedance-seed/seedream-4.5`
- **图生图模型 (Image-to-Image)**: `google/gemini-2.5-flash-image`
- **获取地址**: [OpenRouter | bytedance-seed/seedream-4.5](https://openrouter.ai/bytedance-seed/seedream-4.5)

## OpenClaw 调用方式

### 命令调用

OpenClaw 会通过以下 CLI 方式触发图像生成:

```bash
# 基础生成
node skills/image-generation/scripts/generate.js \
  --prompt "a futuristic city at sunset" \
  --output "outputs/city.png"

# 指定模型、尺寸与比例 (OpenRouter 默认)
node skills/image-generation/scripts/generate.js \
  --prompt "cyberpunk landscape" \
  --model "bytedance-seed/seedream-4.5" \
  --i2i-model "google/gemini-2.5-flash-image" \
  --size "2K" \
  --aspect "16:9"

node skills/image-generation/scripts/generate.js \
  --prompt "cyberpunk landscape" \
  --image "bytedance-seed/seedream-4.5" \
  --model "google/gemini-2.5-flash-image" \
  --size "2K" \
  --aspect "16:9"

# 通过 OpenClaw 包装器调用
node skills/image-generation/scripts/cli/openrouter.js \
  --prompt "abstract oil painting" \
  --size "1K"

### 预检测 (Connectivity Check)

OpenClaw 在启动时可运行以下脚本检查 API 连通性:

```bash
node skills/image-generation/scripts/cli/openrouter.js --test
```

## 参数详解

| 参数 | 说明 | 必填 | 默认值 |
|------|------|------|--------|
| `--prompt` | 图像描述词 | 是 | - |
| `--model` | 文本生图模型 ID | 否 | `bytedance-seed/seedream-4.5` |
| `--i2i-model` | 图生图模型 ID | 否 | `google/gemini-2.5-flash-image` |
| `--input-image`| 图生图输入图片路径 | 否 | - |
| `--list-models` | 列出所有可用模型 | 否 | - |
| `--size` | 分辨率等级 (`1K`\|`2K`\|`4K`) | 否 | 模型默认 |
| `--aspect` | 宽高比 (如 1:1, 16:9) | 否 | `1:1` |
| `--output` | 输出文件路径 | 否 | `.sisyphus/generated/image_<ts>.png` |

|------|------|------|--------|
| `--prompt` | 图像描述词 | 是 | - |
|| `--model` | 图生图模型 ID | 否 | `google/gemini-2.5-flash-image` |
|| `--image` | 文本生图模型 ID | 否 | `bytedance-seed/seedream-4.5` |
| `--list-models` | 列出所有可用模型 | 否 | - |
| `--size` | 分辨率等级 (`1K`\|`2K`\|`4K`) | 否 | 模型默认 |
| `--aspect` | 宽高比 (如 1:1, 16:9) | 否 | `1:1` |
| `--output` | 输出文件路径 | 否 | `.sisyphus/generated/image_<ts>.png` |
| `--output` | 输出文件路径 | 否 | `.sisyphus/generated/image_<ts>.png` |

### 分辨率与尺寸说明

**`--size` 参数格式**:
- 只接受三个字符串值:`"1K"`、`"2K"`、`"4K"`
- **不要** 使用像素格式如 `"1024x1024"` 或 `"3840x2160"`(OpenRouter API 不接受)

**实际输出像素尺寸**(由 `size` 和 `aspect` 共同决定):

| size | aspect | 实际像素 | 说明 |
|------|--------|----------|------|
| `1K` | `1:1` | 1024×1024 | 默认,约 1MP |
| `1K` | `16:9` | ~1280×720 | 约 0.9MP |
| `2K` | `1:1` | 2048×2048 | 约 4MP |
| `2K` | `16:9` | ~2560×1440 | 约 3.7MP |
| `4K` | `1:1` | 4096×4096 | 约 16MP |
| `4K` | `16:9` | 3840×2160 | 标准 4K UHD |

**重要限制**:
1. **并非所有模型都支持 4K**,部分模型最高只支持到 2K
2. **4K 生成成本更高**(约 2 倍于 1K/2K)
3. **推荐**:日常使用 `2K`(与 1K 同价,质量显著提升)

**支持的宽高比**:`1:1`(默认)、`2:3`、`3:2`、`3:4`、`4:3`、`4:5`、`5:4`、`9:16`、`16:9`、`21:9`

## 退出码说明 (Exit Codes)

符合 `cli-contract.md` 规范:

| 代码 | 标签 | 描述 |
|------|-------|-------------|
| `0` | `SUCCESS` | 生成成功并已保存到本地 |
| `1` | `CONFIG_ERROR` | 参数缺失、格式错误或鉴权失败 |
| `2` | `API_ERROR` | OpenRouter API 调用失败(限流、超时等) |
| `3` | `FS_ERROR` | 本地文件系统错误(目录权限、磁盘空间等) |

## 验证与证据 (Verification)

在交付前,请确保通过以下冒烟测试:

1. **环境检查**: `node skills/image-generation/scripts/cli/openrouter.js --test` (返回 success: true)
2. **基础生成**: `node skills/image-generation/scripts/generate.js --prompt "test" --output "test.png"`
3. **路径安全**: 确保生成的图片位于 `.sisyphus/generated/` 或指定的安全路径下。

---

## 资源维护

- **扩展新 Provider**: 参考 `references/extension-guide.md`
- **配置详情**: 参考 `references/configuration.md`
- **CLI 规范**: 参考 `references/cli-contract.md`


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/extension-guide.md

```markdown
# 扩展指南 (Image Generation Extension Guide)

本文档说明如何为图像生成技能扩展新的 Provider 适配器。

> **重要说明**: v1 版本仅支持 **OpenRouter only**。后续版本将根据本指南进行扩展。

---

## Provider 适配器接口 (Adapter Interface)

所有新增的 Provider 必须在 `scripts/cli/` 目录下实现一个适配器脚本,并遵循以下接口规范。

### 核心方法

#### 1. `generate(options)`
执行图像生成任务。
- **参数**:
  - `prompt` (String, 必填): 图像生成描述。
  - `model` (String, 可选): 指定模型 ID。
  - `size` (String, 可选): 分辨率等级 (`1K`|`2K`|`4K`),非像素尺寸。
  - `output` (String, 可选): 输出文件路径。
- **返回值**:
  - 成功时返回包含文件路径的对象:`{ success: true, path: "..." }`。
  - 失败时抛出错误或返回错误对象。

#### 2. `test()`
用于环境检查和连通性测试。
- **行为**: 验证 API Key 有效性及网络可达性,不产生计费生成。
- **返回值**: `{ success: true, message: "..." }`。

---

## 接入步骤 (Registration Steps)

若要添加新的 Provider(例如 Replicate 或 Stability AI),请遵循以下步骤:

### 步骤 1: 创建适配器脚本
在 `scripts/cli/` 下创建新文件,例如 `replicate.js` (Stub Example):

```javascript
// scripts/cli/replicate.js (Stub)
async function generate(params) {
    // 1. 验证环境变量 (REPLICATE_API_TOKEN)
    // 2. 构造 Replicate API 请求
    // 3. 处理响应并保存图片到本地
    // 4. 返回结果
    console.log("Replicate provider not implemented in v1");
}

async function test() {
    // 验证 Token
}

module.exports = { generate, test };
```

### 步骤 2: 定义环境变量
在 `.env` 中定义该 Provider 所需的凭证字段,例如 `REPLICATE_API_TOKEN`。

### 步骤 3: 注册 Provider
在 `scripts/generate.js` 的 Provider 路由逻辑中添加映射:

```javascript
// scripts/generate.js (Logic Stub)
const providers = {
    'openrouter': require('./cli/openrouter'),
    // 'replicate': require('./cli/replicate'), // 未来扩展点
};
```

### 步骤 4: 更新文档
1. 在 `SKILL.md` 的支持列表中添加新 Provider。
2. 在 `references/extension-guide.md` 的更新日志中记录。

---

## 错误处理规范

所有适配器应返回统一的错误结构:
```json
{
  "success": false,
  "error": "PROVIDER_ERROR_CODE",
  "message": "Human readable message"
}
```

---

## 更新日志

| 日期 | 变更内容 |
|------|----------|
| 2025-02-25 | 初始版本,定义适配器接口规范 (adapter interface),明确 v1 仅支持 OpenRouter only |

```

### references/configuration.md

```markdown
# Configuration Specification

This document defines the environment variables, configuration defaults, and validation policy for the `image-generation` skill.

## 1. Configuration Precedence

The following order of precedence is applied when resolving configuration values:

1.  **CLI Arguments** (Highest priority)
2.  **Environment Variables**
3.  **Hardcoded Defaults** (Lowest priority)

## 2. Environment Variables

### Required
- `OPENROUTER_API_KEY`: The API key for OpenRouter. Without this, the skill will fail fast.

### Optional
- `IMAGE_GEN_TEXT_TO_IMAGE_MODEL`: The default text-to-image model.
    - Default value: `bytedance-seed/seedream-4.5`
- `IMAGE_GEN_IMAGE_TO_IMAGE_MODEL`: The default image-to-image model.
    - Default value: `google/gemini-2.5-flash-image`

## 3. Validation Policy

### Missing API Key
If `OPENROUTER_API_KEY` is not set, the skill must:
1.  Fail immediately (Fail Fast).
2.  Exit with a non-zero exit code.
3.  Provide a clear, actionable error message: `Missing OPENROUTER_API_KEY environment variable.`

### Missing Prompt
The `--prompt` CLI argument is mandatory. If missing:
1.  Fail immediately.
2.  Exit with a non-zero exit code.
3.  Provide a clear error message: `The --prompt argument is required.`

## 4. Default Model Mapping

| Source | Model ID |
| :--- | :--- |
| **Hardcoded Default (T2I)** | `bytedance-seed/seedream-4.5` |
| **Hardcoded Default (I2I)** | `google/gemini-2.5-flash-image` |
| **Provider** | OpenRouter |

---
*Note: Future providers will be documented in the [Extension Guide](./extension-guide.md).*

```

### references/cli-contract.md

```markdown
# CLI Contract: Image Generation Skill

This document defines the Command Line Interface (CLI) contract and exit-code policy for the image generation skill.

## CLI Flags

All flags follow the `--flag value` or `--flag=value` pattern.

| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--prompt` | Yes | N/A | The text description of the image to generate. |
| `--model` | No | `bytedance-seed/seedream-4.5` | Text-to-Image Model ID. |
| `--i2i-model`| No | `google/gemini-2.5-flash-image` | Image-to-Image Model ID. |
| `--input-image`| No | N/A | Path to input image for image-to-image. |
| `--size` | No | `1K` | Resolution tier (`1K`, `2K`, or `4K`). Not pixel dimensions.
| `--aspect` | No | `1:1` | The aspect ratio for the generated image. |
| `--output` | No | `.sisyphus/generated/image_<timestamp>.png` | Local path to save the generated image. |

|------|----------|---------|-------------|
| `--prompt` | Yes | N/A | The text description of the image to generate. |
| `--model` | No | `bytedance-seed/seedream-4.5` | The OpenRouter model ID to use. |
| `--size` | No | `1K` | Resolution tier (`1K`, `2K`, or `4K`). Not pixel dimensions.
| `--aspect` | No | `1:1` | The aspect ratio for the generated image. |
| `--output` | No | `.sisyphus/generated/image_<timestamp>.png` | Local path to save the generated image. |

### Configuration Precedence
1. **CLI Arguments**: Highest priority, overrides everything else.
2. **Environment Variables**: e.g., `IMAGE_GEN_TEXT_TO_IMAGE_MODEL`, `IMAGE_GEN_IMAGE_TO_IMAGE_MODEL`, `OPENROUTER_API_KEY`.

3. **Hard Defaults**: Built-in defaults as listed above.

---

## Exit Codes

The CLI will exit with one of the following codes to facilitate programmatic usage by OpenClaw or other orchestrators.

| Code | Label | Description |
|------|-------|-------------|
| `0` | `SUCCESS` | Image generated and saved successfully. |
| `1` | `CONFIG_ERROR` | Missing required parameters, invalid flag values, or authentication/API key issues. |
| `2` | `API_ERROR` | Failure during API communication with OpenRouter (e.g., rate limits, server errors). |
| `3` | `FS_ERROR` | Errors related to the local filesystem (e.g., unwritable output path, disk full). |

---

## JSON Error Payload Schema

When a non-zero exit code is returned, the CLI will output a JSON error payload to `stderr` (or `stdout` depending on implementation, typically following the project's `response-helper.js` pattern).

```json
{
  "success": false,
  "error": "ERROR_CODE",
  "message": "Human readable error message explaining the failure.",
  "details": {},
  "statusCode": 400,
  "timestamp": "2026-02-25T12:00:00.000Z"
}
```

### Fields:
- `success`: Always `false` for errors.
- `error`: A stable, uppercase string code (e.g., `MISSING_PARAMETERS`, `AUTH_FAILED`, `API_TIMEOUT`).
- `message`: A descriptive message for humans.
- `details`: (Optional) Object containing additional context (e.g., which parameter was missing).
- `statusCode`: HTTP-like status code for classification.
- `timestamp`: ISO 8601 timestamp of the error.

---

## Validation Rules

1. **Prompt**: Must be a non-empty string.
2. **Model**: Must be a valid OpenRouter model string.
3. **Size/Aspect**: Should follow `WxH` or `W:H` formats; implementation may restrict to supported values.
4. **Output**: Parent directory must exist or be creatable; file must be writable.

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "goforu",
  "slug": "phoenixclaw-image-gen",
  "displayName": "phoenixclaw image gen",
  "latest": {
    "version": "0.2.1",
    "publishedAt": 1772114838661,
    "commit": "https://github.com/openclaw/skills/commit/ee5de4494b6c9d26f54072d27513ccdbc6c2ea6c"
  },
  "history": []
}

```

### references/resolution-guide.md

```markdown
# OpenRouter 图像生成分辨率指南

本文档详细说明 OpenRouter 图像生成 API 的分辨率限制、参数格式和实际输出尺寸。

## 参数格式

### `--size` / `image_config.image_size`

**只接受以下三个字符串值**:

- `"1K"` - 标准分辨率(默认)
- `"2K"` - 高分辨率
- `"4K"` - 最高分辨率

**⚠️ 重要**:
- 不要使用像素格式如 `"1024x1024"`、`"2048x2048"` 或 `"3840x2160"`
- OpenRouter API 不接受像素尺寸,只接受上述三个字符串值

## 实际输出像素尺寸

实际输出的像素尺寸由 **`size` 和 `aspect_ratio` 共同决定**。

### 常见组合的实际像素

| size | aspect | 实际像素 | 约等于 | 说明 |
|------|--------|----------|--------|------|
| `1K` | `1:1` | 1024×1024 | ~1 MP | 默认正方形 |
| `1K` | `16:9` | ~1344×768 | ~1 MP |  widescreen |
| `2K` | `1:1` | 2048×2048 | ~4 MP | 高质量正方形 |
| `2K` | `16:9` | ~2560×1440 | ~3.7 MP | 接近 1440p |
| `4K` | `1:1` | 4096×4096 | ~16 MP | 超高清正方形 |
| `4K` | `16:9` | 3840×2160 | ~8.3 MP | 标准 4K UHD |

**注意**:实际像素可能因模型而异,OpenRouter 会根据模型能力进行调整。

## 模型支持情况

### 完整支持 4K 的模型

- `google/gemini-3-pro-image-preview` (Nano Banana Pro)
- `google/gemini-2.5-flash-image-preview` (Nano Banana)
- `black-forest-labs/flux.2-pro`
- `black-forest-labs/flux.2-max`

### 仅支持到 2K 的模型

- `bytedance-seed/seedream-4.5`(可能,需验证)
- `openai/dall-e-3`
- 部分其他模型

### 使用建议

1. **日常使用推荐 `2K`**:
   - 与 `1K` 价格相同
   - 质量显著提升(4倍像素)
   - 大多数模型都支持

2. **4K 使用场景**:
   - 打印用途
   - 大尺寸展示
   - 需要后期裁剪的素材
   - **注意**:4K 价格通常是 2K 的 1.5-2 倍

## 常见问题

### Q: 为什么我设置了 `--size 4K` 但输出只有 ~1280px?

**可能原因**:
1. **模型不支持 4K**:部分模型最高只输出 2K
2. **参数格式错误**:确保传递的是 `"4K"` 字符串,而不是 `"4096x4096"`
3. **OpenRouter 路由问题**:API 可能路由到不支持 4K 的提供商

**排查步骤**:
1. 确认使用的模型支持 4K(查看上方模型支持列表)
2. 检查参数传递是否正确(查看 CLI 调试输出)
3. 尝试使用 `google/gemini-3-pro-image-preview` 测试(确认支持 4K)

### Q: 为什么 4K + 16:9 输出的是 3840×2160 而不是 4096×2304?

**解释**:
- `4K` 指的是长边的目标像素数(约 4096px)
- 当使用 `16:9` 宽高比时,OpenRouter 保持 4K UHD 标准分辨率(3840×2160)
- 这是符合行业标准(如 4K TV/显示器分辨率)的行为

### Q: 如何获得最大的输出尺寸?

**使用 `4K` + `1:1` 组合**:
```bash
node generate.js --prompt "..." --size 4K --aspect 1:1
```
这会输出约 4096×4096 像素的图像(约 16MP)。

## 支持的宽高比

所有 OpenRouter 图像生成模型支持以下宽高比:

| 比例 | 典型用途 |
|------|----------|
| `1:1` | 社交媒体、头像、产品图(默认)|
| `16:9` | 视频缩略图、桌面壁纸、横幅 |
| `9:16` | 手机壁纸、短视频封面、Story |
| `21:9` | 超宽屏、电影画面 |
| `4:3` | 传统照片、文档插图 |
| `3:4` | 杂志封面、海报 |
| `3:2` | 摄影作品 |
| `2:3` | 人像摄影 |
| `4:5` | Instagram 帖子 |
| `5:4` | 打印照片 |

## 价格参考(Nano Banana Pro)

| 分辨率 | 价格 | 性价比 |
|--------|------|--------|
| `1K` | $0.134 | 基准 |
| `2K` | $0.134 | ⭐ 最高(同价,4倍像素)|
| `4K` | $0.24 | 高(约 2x 价格,16倍像素)|

## API 调用示例

### 生成 4K UHD 图像
```json
{
  "model": "google/gemini-3-pro-image-preview",
  "messages": [{"role": "user", "content": "A cinematic mountain landscape"}],
  "modalities": ["image"],
  "image_config": {
    "image_size": "4K",
    "aspect_ratio": "16:9"
  }
}
```

### 生成高质量正方形图像
```json
{
  "model": "google/gemini-3-pro-image-preview",
  "messages": [{"role": "user", "content": "Abstract geometric art"}],
  "modalities": ["image"],
  "image_config": {
    "image_size": "2K",
    "aspect_ratio": "1:1"
  }
}
```

## 相关文档

- [SKILL.md](../SKILL.md) - 技能使用指南
- [cli-contract.md](./cli-contract.md) - CLI 接口规范
- [OpenRouter 官方文档](https://openrouter.ai/docs/guides/overview/multimodal/image-generation)

```

### scripts/adapters/openrouter.js

```javascript
/**
 * OpenRouter Adapter for Image Generation
 *
 * Provides request building, response parsing, and API communication
 * for OpenRouter's image generation capabilities.
 *
 * @module openrouter-adapter
 */

const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const DEFAULT_TEXT_TO_IMAGE_MODEL = process.env.IMAGE_GEN_TEXT_TO_IMAGE_MODEL || 'bytedance-seed/seedream-4.5';
const DEFAULT_IMAGE_TO_IMAGE_MODEL = process.env.IMAGE_GEN_IMAGE_TO_IMAGE_MODEL || 'google/gemini-2.5-flash-image';

/**
 * Error codes matching cli-contract.md schema
 */
const ErrorCodes = {
    CONFIG_ERROR: 'CONFIG_ERROR',
    API_ERROR: 'API_ERROR',
    PARSE_ERROR: 'PARSE_ERROR',
    NETWORK_ERROR: 'NETWORK_ERROR'
};

/**
 * Creates a structured error object following cli-contract.md schema
 *
 * @param {string} code - Error code from ErrorCodes
 * @param {string} message - Human-readable error message
 * @param {number} statusCode - HTTP-like status code
 * @param {Object} [details={}] - Additional error details
 * @returns {Object} Structured error object
 */
function createError(code, message, statusCode, details = {}) {
    return {
        success: false,
        error: code,
        message: message,
        details: details,
        statusCode: statusCode,
        timestamp: new Date().toISOString()
    };
}

/**
 * Builds the OpenRouter request payload for image generation
 *
 * @param {string} imageToImageModel - The OpenRouter model ID to use for image-to-image
 * @param {string} textToImageModel - The OpenRouter model ID to use for text-to-image
 * @param {string} prompt - The text description of the image to generate
 * @param {string} [size] - Optional resolution tier ('1K', '2K', or '4K')
 * @param {string} [aspectRatio] - Optional aspect ratio (e.g., '1:1')
 * @param {string} [inputImageBase64] - Optional base64 encoded input image for i2i
 * @returns {Object} Request payload for OpenRouter API
 */
function buildRequest(imageToImageModel, textToImageModel, prompt, size, aspectRatio, inputImageBase64) {
    const defaultModel = inputImageBase64 ? DEFAULT_IMAGE_TO_IMAGE_MODEL : DEFAULT_TEXT_TO_IMAGE_MODEL;
    const model = inputImageBase64 ? (imageToImageModel || defaultModel) : (textToImageModel || defaultModel);

    const messages = [
        {
            role: 'user',
            content: inputImageBase64 ? [
                { type: 'text', text: prompt },
                { type: 'image_url', image_url: { url: `data:image/png;base64,${inputImageBase64}` } }
            ] : prompt
        }
    ];

    const payload = {
        model: model,
        messages: messages,
        modalities: ['image']
    };

    // Build image_config object for OpenRouter API
    const imageConfig = {};

    if (size) {
        imageConfig.image_size = size;
    }

    if (aspectRatio) {
        imageConfig.aspect_ratio = aspectRatio;
    }

    // Only add image_config if there are configuration options
    if (Object.keys(imageConfig).length > 0) {
        payload.image_config = imageConfig;
    }

    return payload;
}

/**
 * Parses the OpenRouter response to extract the base64 image URL
 *
 * Expected response structure:
 * choices[0].message.images[0].image_url.url
 *
 * @param {Object} response - The JSON response from OpenRouter API
 * @returns {Object} Parsed result with success flag and data or error
 */
function parseResponse(response) {
    try {
        // Validate response structure
        if (!response) {
            return {
                success: false,
                error: createError(
                    ErrorCodes.PARSE_ERROR,
                    'Empty response from API',
                    500
                )
            };
        }

        // Check for API-level errors
        if (response.error) {
            return {
                success: false,
                error: createError(
                    ErrorCodes.API_ERROR,
                    response.error.message || 'API returned an error',
                    response.error.code || 500,
                    { apiError: response.error }
                )
            };
        }

        // Validate choices array exists
        if (!response.choices || !Array.isArray(response.choices) || response.choices.length === 0) {
            return {
                success: false,
                error: createError(
                    ErrorCodes.PARSE_ERROR,
                    'Invalid response: missing choices array',
                    500,
                    { response: response }
                )
            };
        }

        const firstChoice = response.choices[0];

        // Validate message structure
        if (!firstChoice.message) {
            return {
                success: false,
                error: createError(
                    ErrorCodes.PARSE_ERROR,
                    'Invalid response: missing message in choice',
                    500
                )
            };
        }

        // Validate images array
        if (!firstChoice.message.images || !Array.isArray(firstChoice.message.images) || firstChoice.message.images.length === 0) {
            return {
                success: false,
                error: createError(
                    ErrorCodes.PARSE_ERROR,
                    'Invalid response: missing images array in message',
                    500
                )
            };
        }

        const firstImage = firstChoice.message.images[0];

        // Validate image_url structure
        if (!firstImage.image_url || !firstImage.image_url.url) {
            return {
                success: false,
                error: createError(
                    ErrorCodes.PARSE_ERROR,
                    'Invalid response: missing image_url.url in image object',
                    500
                )
            };
        }

        // Extract the base64 image URL
        const imageUrl = firstImage.image_url.url;

        // Validate that it's a data URL (base64)
        if (!imageUrl.startsWith('data:image/')) {
            return {
                success: false,
                error: createError(
                    ErrorCodes.PARSE_ERROR,
                    'Invalid response: image URL is not a base64 data URL',
                    500
                )
            };
        }

        return {
            success: true,
            data: {
                imageUrl: imageUrl,
                model: response.model,
                usage: response.usage || null
            },
            timestamp: new Date().toISOString()
        };
    } catch (error) {
        return {
            success: false,
            error: createError(
                ErrorCodes.PARSE_ERROR,
                `Failed to parse response: ${error.message}`,
                500,
                { parseError: error.message }
            )
        };
    }
}

/**
 * Generates an image using OpenRouter API
 *
 * @param {string} apiKey - The OpenRouter API key
 * @param {string} imageToImageModel - The image-to-image model ID to use
 * @param {string} textToImageModel - The text-to-image model ID to use
 * @param {string} prompt - The text prompt for image generation
 * @param {string} [size] - Optional size specification
 * @param {string} [aspectRatio] - Optional aspect ratio
 * @param {string} [inputImageBase64] - Optional base64 encoded input image
 * @returns {Promise<Object>} Result object with success flag and image data or error
 */
async function generateImage(apiKey, imageToImageModel, textToImageModel, prompt, size, aspectRatio, inputImageBase64) {
    // Validate API key
    if (!apiKey) {
        return {
            success: false,
            error: createError(
                ErrorCodes.CONFIG_ERROR,
                'Missing OPENROUTER_API_KEY environment variable.',
                401
            )
        };
    }

    // Validate prompt
    if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
        return {
            success: false,
            error: createError(
                ErrorCodes.CONFIG_ERROR,
                'The --prompt argument is required.',
                400,
                { field: 'prompt' }
            )
        };
    }

    // Build request payload
    const payload = buildRequest(imageToImageModel, textToImageModel, prompt, size, aspectRatio, inputImageBase64);

    try {
        const response = await fetch(OPENROUTER_API_URL, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${apiKey}`,
                'Content-Type': 'application/json',
                'HTTP-Referer': 'https://github.com/ohmyopencodelabs/ai-workers',
                'X-Title': 'AI Workers Image Generation'
            },
            body: JSON.stringify(payload)
        });

        // Handle HTTP errors
        if (!response.ok) {
            const errorBody = await response.text();
            let parsedError;
            try {
                parsedError = JSON.parse(errorBody);
            } catch {
                parsedError = { message: errorBody };
            }

            return {
                success: false,
                error: createError(
                    ErrorCodes.API_ERROR,
                    parsedError.error?.message || `API request failed: ${response.status} ${response.statusText}`,
                    response.status,
                    { apiResponse: parsedError }
                )
            };
        }

        // Parse JSON response
        const data = await response.json();

        // Parse and return result
        return parseResponse(data);

    } catch (error) {
        // Handle network/fetch errors
        if (error.name === 'TypeError' || error.name === 'FetchError') {
            return {
                success: false,
                error: createError(
                    ErrorCodes.NETWORK_ERROR,
                    `Network error: ${error.message}`,
                    503
                )
            };
        }

        return {
            success: false,
            error: createError(
                ErrorCodes.API_ERROR,
                `Request failed: ${error.message}`,
                500,
                { error: error.message }
            )
        };
    }
}

/**
 * Tests OpenRouter connectivity without generating an image
 * Validates that OPENROUTER_API_KEY is configured
 *
 * @param {string} [apiKey] - Optional API key (defaults to process.env.OPENROUTER_API_KEY)
 * @returns {Promise<Object>} Test result with success flag and details or error
 */
async function test(apiKey) {
    const key = apiKey || process.env.OPENROUTER_API_KEY;

    if (!key) {
        return {
            success: false,
            error: createError(
                ErrorCodes.CONFIG_ERROR,
                'Missing OPENROUTER_API_KEY environment variable.',
                401
            )
        };
    }

    // Validate API key format (should be a non-empty string)
    if (typeof key !== 'string' || key.trim().length === 0) {
        return {
            success: false,
            error: createError(
                ErrorCodes.CONFIG_ERROR,
                'OPENROUTER_API_KEY is empty or invalid.',
                401
            )
        };
    }

    // Note: We don't make an actual API call here to avoid consuming tokens
    // For now, we validate the key exists and is properly formatted

    return {
        success: true,
        data: {
            apiKeyConfigured: true,
            keyPrefix: key.substring(0, 8) + '...',
            endpoint: OPENROUTER_API_URL,
            defaultTextToImageModel: DEFAULT_TEXT_TO_IMAGE_MODEL,
            defaultImageToImageModel: DEFAULT_IMAGE_TO_IMAGE_MODEL
        },
        message: 'OpenRouter API key is configured. Test passed.',
        timestamp: new Date().toISOString()
    };
}

module.exports = {
    buildRequest,
    parseResponse,
    generateImage,
    test,
    ErrorCodes,
    DEFAULT_TEXT_TO_IMAGE_MODEL,
    DEFAULT_IMAGE_TO_IMAGE_MODEL,
    OPENROUTER_API_URL,
    createError
};

```

### scripts/cli/openrouter.js

```javascript
#!/usr/bin/env node

/**
 * OpenRouter CLI Wrapper for OpenClaw
 * 
 * Provides connectivity precheck via --test and delegates 
 * image generation requests to the core generate.js script.
 * 
 * @module openrouter-wrapper
 */

const { spawn } = require('child_process');
const path = require('path');
const adapter = require('../adapters/openrouter');

const args = process.argv.slice(2);

/**
 * Fetches and displays available image models from OpenRouter
 */
async function listModels() {
    try {
        const apiKey = process.env.OPENROUTER_API_KEY;
        const defaultModel = process.env.IMAGE_GEN_TEXT_TO_IMAGE_MODEL || 'bytedance-seed/seedream-4.5';
        
        if (!apiKey) {
            console.error('Error: OPENROUTER_API_KEY not set');
            process.exit(1);
        }
        
        const response = await fetch('https://openrouter.ai/api/v1/models', {
            headers: { 'Authorization': `Bearer ${apiKey}` }
        });
        
        if (!response.ok) {
            const errorBody = await response.text();
            throw new Error(`HTTP error! status: ${response.status}. Body: ${errorBody}`);
        }
        
        const data = await response.json();

        // Validate response format
        if (!data || !data.data || !Array.isArray(data.data)) {
            console.error('Error: Unexpected response format from OpenRouter API');
            process.exit(1);
        }
        
        // Filter for image models
        const imageModels = data.data.filter(m => 
            m.id.includes('image') || 
            m.architecture?.modality === 'image' ||
            m.id.includes('seedream') ||
            m.id.includes('dall-e') ||
            m.id.includes('imagen')
        );
        
        if (imageModels.length === 0) {
            console.log('No image models found on OpenRouter.');
            process.exit(0);
        }

        // Sort by model ID
        imageModels.sort((a, b) => a.id.localeCompare(b.id));
        
        console.log('Available Image Models:\n');
        imageModels.forEach(model => {
            const isDefault = model.id === defaultModel;
            const prefix = isDefault ? ' [DEFAULT] ' : '  ';
            console.log(`${prefix}${model.id}`);
            console.log(`    Name: ${model.name || 'N/A'}`);
            console.log(`    Description: ${model.description || 'N/A'}\n`);
        });
        process.exit(0);
    } catch (error) {
        console.error('Failed to fetch models:', error.message);
        process.exit(1);
    }
}
/**
 * Handles the --test flag to validate environment and connectivity
 */
async function handleTest() {
    try {
        const result = await adapter.test();
        if (result.success) {
            console.log(JSON.stringify(result, null, 2));
            process.exit(0);
        } else {
            console.error(JSON.stringify(result.error || result, null, 2));
            process.exit(1);
        }
    } catch (error) {
        console.error(JSON.stringify(adapter.createError(
            adapter.ErrorCodes.API_ERROR,
            `Precheck failed: ${error.message}`,
            500
        ), null, 2));
        process.exit(1);
    }
}

/**
 * Delegates CLI arguments to the core generate.js script
 * 
 * @param {string[]} cliArgs - Arguments to delegate
 */
function delegateToCore(cliArgs) {
    const coreScript = path.resolve(__dirname, '../generate.js');
    
    // Check if core script exists
    const fs = require('fs');
    if (!fs.existsSync(coreScript)) {
        console.error(JSON.stringify(adapter.createError(
            adapter.ErrorCodes.CONFIG_ERROR,
            'Core generation script not found. Ensure Task 7 is complete.',
            500
        ), null, 2));
        process.exit(1);
    }

    const child = spawn('node', [coreScript, ...cliArgs], {
        stdio: 'inherit',
        env: process.env
    });

    child.on('close', (code) => {
        process.exit(code || 0);
    });

    child.on('error', (err) => {
        console.error(JSON.stringify(adapter.createError(
            adapter.ErrorCodes.API_ERROR,
            `Failed to delegate to core script: ${err.message}`,
            500
        ), null, 2));
        process.exit(1);
    });
}

// Main CLI logic
if (args.includes('--list-models')) {
    listModels();
} else if (args.includes('--test') || args.includes('-t')) {
    handleTest();
} else if (args.length > 0 && args.some(arg => arg.startsWith('--'))) {
    delegateToCore(args);
} else {
    // Show usage
    console.log('OpenRouter Image Generation Wrapper for OpenClaw');
    console.log('');
    console.log('Usage:');
    console.log('  node openrouter.js --test               Precheck connectivity and configuration');
    console.log('  node openrouter.js --list-models         List available image models from OpenRouter');
    console.log('  node openrouter.js --prompt <text>      Generate image (delegates to generate.js)');
    console.log('  node openrouter.js [options]            Additional flags: --model, --i2i-model, --input-image, --size, --output');
    console.log('');
    console.log('Environment:');
    console.log('  OPENROUTER_API_KEY                      Required for authentication');
    console.log('');
    process.exit(0);
}

```

### scripts/generate.js

```javascript
#!/usr/bin/env node

/**
 * Image Generation CLI Script
 *
 * Main entry point for image generation with:
 * - Output path safety validation
 * - Retry logic for transient errors
 * - Atomic file writes
 * - Deterministic error codes per cli-contract.md
 *
 * Exit Codes:
 *   0 = SUCCESS
 *   1 = CONFIG_ERROR (missing params, invalid flags, auth issues)
 *   2 = API_ERROR (rate limits, server errors)
 *   3 = FS_ERROR (filesystem issues)
 *
 * @module generate
 */

const fs = require('fs');
const path = require('path');

const openrouter = require('./adapters/openrouter');

// Exit codes matching cli-contract.md
const ExitCodes = {
    SUCCESS: 0,
    CONFIG_ERROR: 1,
    API_ERROR: 2,
    FS_ERROR: 3
};

// Retry configuration
const RETRY_CONFIG = {
    maxAttempts: 3,
    delays: [1000, 2000, 4000] // Exponential backoff: 1s, 2s, 4s
};

// Transient HTTP status codes that should trigger retry
const TRANSIENT_STATUS_CODES = [429, 502, 503];

/**
 * Creates a structured error object following cli-contract.md schema
 *
 * @param {string} code - Error code
 * @param {string} message - Human-readable error message
 * @param {number} statusCode - HTTP-like status code
 * @param {Object} [details={}] - Additional error details
 * @returns {Object} Structured error object
 */
function createError(code, message, statusCode, details = {}) {
    return {
        success: false,
        error: code,
        message: message,
        details: details,
        statusCode: statusCode,
        timestamp: new Date().toISOString()
    };
}

/**
 * Validates that an output path is safe (no directory traversal)
 *
 * Rejects:
 * - Paths containing '..' (directory traversal)
 * - Paths starting with '~' (home directory)
 * - Absolute paths outside current working directory
 * - Paths with null bytes
 *
 * @param {string} outputPath - The path to validate
 * @returns {Object} Validation result { valid: boolean, error?: Object }
 */
function validateOutputPath(outputPath) {
    // Check for null bytes
    if (outputPath.includes('\0')) {
        return {
            valid: false,
            error: createError(
                'CONFIG_ERROR',
                'Output path contains invalid null bytes',
                400,
                { path: outputPath }
            )
        };
    }

    // Reject paths with directory traversal
    if (outputPath.includes('..')) {
        return {
            valid: false,
            error: createError(
                'CONFIG_ERROR',
                'Output path contains directory traversal ("..") which is not allowed',
                400,
                { path: outputPath }
            )
        };
    }

    // Reject paths starting with ~ (home directory)
    if (outputPath.startsWith('~')) {
        return {
            valid: false,
            error: createError(
                'CONFIG_ERROR',
                'Output path cannot start with "~" (home directory)',
                400,
                { path: outputPath }
            )
        };
    }

    // Resolve to absolute path
    const resolvedPath = path.resolve(outputPath);
    const cwd = process.cwd();

    // For absolute paths, ensure they're within cwd
    if (path.isAbsolute(outputPath)) {
        // Check if path is within current working directory
        const relativePath = path.relative(cwd, resolvedPath);
        if (relativePath.startsWith('..') || relativePath === '') {
            return {
                valid: false,
                error: createError(
                    'CONFIG_ERROR',
                    'Absolute output path must be within current working directory',
                    400,
                    { path: outputPath, cwd: cwd }
                )
            };
        }
    }

    return { valid: true, resolvedPath };
}

/**
 * Ensures the parent directory exists, creating it if necessary
 *
 * @param {string} filePath - Path to the file
 * @returns {Object} Result { success: boolean, error?: Object }
 */
function ensureDirectoryExists(filePath) {
    const parentDir = path.dirname(filePath);

    try {
        // Check if directory exists
        const stats = fs.statSync(parentDir);
        if (!stats.isDirectory()) {
            return {
                success: false,
                error: createError(
                    'FS_ERROR',
                    `Parent path exists but is not a directory: ${parentDir}`,
                    500,
                    { path: parentDir }
                )
            };
        }
        return { success: true };
    } catch (error) {
        // Directory doesn't exist, try to create it
        if (error.code === 'ENOENT') {
            try {
                fs.mkdirSync(parentDir, { recursive: true });
                return { success: true };
            } catch (mkdirError) {
                return {
                    success: false,
                    error: createError(
                        'FS_ERROR',
                        `Failed to create output directory: ${mkdirError.message}`,
                        500,
                        { path: parentDir, error: mkdirError.message }
                    )
                };
            }
        }

        return {
            success: false,
            error: createError(
                'FS_ERROR',
                `Failed to access output directory: ${error.message}`,
                500,
                { path: parentDir, error: error.message }
            )
        };
    }
}

/**
 * Writes a file atomically by writing to a temp file and renaming
 *
 * This prevents partial file corruption on crash or failure.
 *
 * @param {string} filePath - Final destination path
 * @param {Buffer} data - File data to write
 * @returns {Object} Result { success: boolean, error?: Object }
 */
function writeFileAtomic(filePath, data) {
    const tempPath = `${filePath}.tmp.${Date.now()}.${process.pid}`;

    try {
        // Write to temp file
        fs.writeFileSync(tempPath, data);

        // Verify temp file was written correctly
        const tempStats = fs.statSync(tempPath);
        if (tempStats.size !== data.length) {
            // Clean up temp file
            try {
                fs.unlinkSync(tempPath);
            } catch {
                // Ignore cleanup errors
            }
            return {
                success: false,
                error: createError(
                    'FS_ERROR',
                    'Temp file size mismatch - partial write detected',
                    500,
                    { expected: data.length, actual: tempStats.size }
                )
            };
        }

        // Atomic rename
        fs.renameSync(tempPath, filePath);

        return { success: true };
    } catch (error) {
        // Clean up temp file on error
        try {
            if (fs.existsSync(tempPath)) {
                fs.unlinkSync(tempPath);
            }
        } catch {
            // Ignore cleanup errors
        }

        return {
            success: false,
            error: createError(
                'FS_ERROR',
                `Failed to write output file: ${error.message}`,
                500,
                { path: filePath, error: error.message }
            )
        };
    }
}

/**
 * Sleeps for the specified number of milliseconds
 *
 * @param {number} ms - Milliseconds to sleep
 * @returns {Promise<void>}
 */
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

/**
 * Checks if an error is a transient error that should trigger retry
 *
 * @param {Object} result - The result object from generateImage
 * @returns {boolean} True if the error is transient
 */
function isTransientError(result) {
    if (result.success) {
        return false;
    }

    const statusCode = result.error?.statusCode;
    const errorCode = result.error?.error;

    // Check for transient HTTP status codes
    if (statusCode && TRANSIENT_STATUS_CODES.includes(statusCode)) {
        return true;
    }

    // Check for network errors (typically connection issues)
    if (errorCode === 'NETWORK_ERROR') {
        return true;
    }

    // Check for rate limit or timeout indicators in message
    const message = result.error?.message || '';
    const transientIndicators = [
        'rate limit',
        'rate-limit',
        'too many requests',
        'timeout',
        'temporary',
        'unavailable',
        'overloaded'
    ];

    return transientIndicators.some(indicator =>
        message.toLowerCase().includes(indicator)
    );
}

/**
 * @param {string} apiKey - OpenRouter API key
 * @param {string} imageToImageModel - Image-to-image model ID
 * @param {string} textToImageModel - Text-to-image model ID
 * @param {string} prompt - Generation prompt
 * @param {string} [size] - Image size
 * @param {string} [aspectRatio] - Aspect ratio
 * @param {string} [inputImagePath] - Path to input image for i2i
 * @returns {Promise<Object>} Result with success flag and data or error
 */
async function generateImageWithRetry(apiKey, imageToImageModel, textToImageModel, prompt, size, aspectRatio, inputImagePath) {
    let lastResult = null;
    let inputImageBase64 = null;

    if (inputImagePath) {
        try {
            const imageBuffer = fs.readFileSync(inputImagePath);
            inputImageBase64 = imageBuffer.toString('base64');
        } catch (error) {
            return {
                success: false,
                error: createError(
                    'FS_ERROR',
                    `Failed to read input image: ${error.message}`,
                    400,
                    { path: inputImagePath }
                )
            };
        }
    }

    for (let attempt = 1; attempt <= RETRY_CONFIG.maxAttempts; attempt++) {
        const result = await openrouter.generateImage(
            apiKey,
            imageToImageModel,
            textToImageModel,
            prompt,
            size,
            aspectRatio,
            inputImageBase64
        );

        if (result.success) {
            return result;
        }

        lastResult = result;

        // Check if this is a transient error that should be retried
        if (isTransientError(result) && attempt < RETRY_CONFIG.maxAttempts) {
            const delay = RETRY_CONFIG.delays[attempt - 1] || 4000;
            console.error(JSON.stringify({
                warning: 'Transient error, retrying',
                attempt: attempt,
                maxAttempts: RETRY_CONFIG.maxAttempts,
                delayMs: delay,
                error: result.error?.message,
                timestamp: new Date().toISOString()
            }));
            await sleep(delay);
            continue;
        }

        // Non-transient error or max attempts reached
        break;
    }

    // Add retry information to final error
    if (lastResult && !lastResult.success) {
        lastResult.error.details = {
            ...lastResult.error.details,
            retryAttempts: RETRY_CONFIG.maxAttempts
        };
    }

    return lastResult;
}

/**
 * Maps error codes to exit codes per cli-contract.md
 *
 * @param {Object} errorResult - The error result from generateImage
 * @returns {number} Exit code
 */
function mapErrorToExitCode(errorResult) {
    const errorCode = errorResult.error?.error;

    switch (errorCode) {
        case 'CONFIG_ERROR':
            return ExitCodes.CONFIG_ERROR;
        case 'API_ERROR':
        case 'NETWORK_ERROR':
            return ExitCodes.API_ERROR;
        case 'FS_ERROR':
        case 'PARSE_ERROR':
            return ExitCodes.FS_ERROR;
        default:
            // Default to API_ERROR for unknown errors
            return ExitCodes.API_ERROR;
    }
}

/**
 * Parses CLI arguments into a map
 *
 * @param {string[]} args - Process arguments
 * @returns {Object} Parsed arguments map
 */
function parseArgs(args) {
    const argMap = {};
    for (let i = 0; i < args.length; i++) {
        const arg = args[i];
        if (arg.startsWith('--')) {
            const key = arg.replace(/^--/, '');
            const value = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true;
            argMap[key] = value;
            if (value !== true) i++;
        }
    }
    return argMap;
}

/**
 * Main execution function
 *
 * @returns {Promise<number>} Exit code
 */
async function main() {
    const args = process.argv.slice(2);
    const argMap = parseArgs(args);

    // Handle help flag
    if (argMap.help || argMap.h) {
        console.log('Image Generation CLI');
        console.log('');
        console.log('Usage: node generate.js [options]');
        console.log('');
        console.log('Options:');
        console.log('  --prompt <text>     Image generation prompt (required)');
        console.log('  --model <id>        Text-to-Image Model ID (env: IMAGE_GEN_TEXT_TO_IMAGE_MODEL, default: bytedance-seed/seedream-4.5)');
        console.log('  --i2i-model <id>    Image-to-Image Model ID (env: IMAGE_GEN_IMAGE_TO_IMAGE_MODEL, default: google/gemini-2.5-flash-image)');
        console.log('  --input-image <path> Input image file for image-to-image');
        console.log('  --size <1K|2K|4K>  Image resolution tier (default: model default)');
        console.log('  --aspect <ratio>    Aspect ratio (e.g., 1:1)');
        console.log('  --output <path>     Output file path (default: .sisyphus/generated/image_<timestamp>.png)');
        console.log('  --help              Show this help');
        console.log('');
        console.log('Exit Codes:');
        console.log('  0  SUCCESS');
        console.log('  1  CONFIG_ERROR (missing params, invalid flags, auth)');
        console.log('  2  API_ERROR (rate limits, server errors)');
        console.log('  3  FS_ERROR (filesystem issues)');
        console.log('');
        console.log('Environment Variables:');
        console.log('  OPENROUTER_API_KEY  Required API key for OpenRouter');
        console.log('');
        console.log('Examples:');
        console.log('  node generate.js --prompt "a sunset over mountains"');
        console.log('  node generate.js --prompt "a cat" --output ./images/cat.png');
        console.log('  node generate.js --prompt "a city" --size 2K --aspect 16:9');
        return ExitCodes.SUCCESS;
    }

    // Validate required parameters
    if (!argMap.prompt) {
        const error = createError(
            'CONFIG_ERROR',
            'The --prompt argument is required. Use --help for usage information.',
            400,
            { field: 'prompt' }
        );
        console.error(JSON.stringify(error, null, 2));
        return ExitCodes.CONFIG_ERROR;
    }

    const apiKey = process.env.OPENROUTER_API_KEY;
    if (!apiKey) {
        const error = createError(
            'CONFIG_ERROR',
            'Missing OPENROUTER_API_KEY environment variable.',
            401
        );
        console.error(JSON.stringify(error, null, 2));
        return ExitCodes.CONFIG_ERROR;
    }

    // Determine output path
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
    const defaultOutput = path.join('.sisyphus', 'generated', `image_${timestamp}.png`);
    const outputPath = argMap.output || defaultOutput;

    // Validate output path safety
    const pathValidation = validateOutputPath(outputPath);
    if (!pathValidation.valid) {
        console.error(JSON.stringify(pathValidation.error, null, 2));
        return ExitCodes.CONFIG_ERROR;
    }

    const resolvedOutputPath = pathValidation.resolvedPath;

    // Ensure parent directory exists
    const dirResult = ensureDirectoryExists(resolvedOutputPath);
    if (!dirResult.success) {
        console.error(JSON.stringify(dirResult.error, null, 2));
        return ExitCodes.FS_ERROR;
    }

    // Generate image with retry logic
    const result = await generateImageWithRetry(
        apiKey,
        argMap['i2i-model'],
        argMap.model,
        argMap.prompt,
        argMap.size,
        argMap.aspect,
        argMap['input-image']
    );

    if (!result.success) {
        const exitCode = mapErrorToExitCode(result);
        console.error(JSON.stringify(result.error, null, 2));
        return exitCode;
    }

    // Extract and decode base64 image
    let imageData;
    try {
        const base64Data = result.data.imageUrl.replace(/^data:image\/\w+;base64,/, '');
        imageData = Buffer.from(base64Data, 'base64');
    } catch (error) {
        const fsError = createError(
            'FS_ERROR',
            `Failed to decode image data: ${error.message}`,
            500,
            { error: error.message }
        );
        console.error(JSON.stringify(fsError, null, 2));
        return ExitCodes.FS_ERROR;
    }

    // Write image file atomically
    const writeResult = writeFileAtomic(resolvedOutputPath, imageData);
    if (!writeResult.success) {
        console.error(JSON.stringify(writeResult.error, null, 2));
        return ExitCodes.FS_ERROR;
    }

    // Success output
    const successOutput = {
        success: true,
        data: {
            outputPath: resolvedOutputPath,
            model: result.data.model,
            usage: result.data.usage
        },
        message: 'Image generated successfully',
        timestamp: new Date().toISOString()
    };

    console.log(JSON.stringify(successOutput, null, 2));
    return ExitCodes.SUCCESS;
}

// Execute main function
main().then(exitCode => {
    process.exit(exitCode);
}).catch(error => {
    const unexpectedError = createError(
        'API_ERROR',
        `Unexpected error: ${error.message}`,
        500,
        { stack: error.stack }
    );
    console.error(JSON.stringify(unexpectedError, null, 2));
    process.exit(ExitCodes.API_ERROR);
});

```

image-generation | SkillHub