mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 06:19:46 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 484b00109f | |||
| ac217d5402 | |||
| 70d9f63727 | |||
| 3398509d9e | |||
| a11613c11b | |||
| cb17cb9cca | |||
| 88d6e09472 | |||
| c151f33775 | |||
| 32003da694 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.65.1"
|
||||
"version": "1.66.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
node-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.66.0 - 2026-03-13
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add Jimeng (即梦) and Seedream (豆包) image generation providers (by @lindaifeng)
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: tighten Jimeng provider behavior
|
||||
|
||||
### Refactor
|
||||
- `baoyu-image-gen`: export functions for testability and add module entry guard
|
||||
|
||||
### Documentation
|
||||
- `baoyu-image-gen`: add Jimeng and Seedream provider documentation to SKILL.md and READMEs
|
||||
|
||||
### Tests
|
||||
- Add test infrastructure with CI workflow and image-gen unit tests
|
||||
|
||||
## 1.65.1 - 2026-03-13
|
||||
|
||||
### Refactor
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.66.0 - 2026-03-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增即梦(Jimeng)和豆包(Seedream)图像生成服务商 (by @lindaifeng)
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:收紧即梦服务商行为
|
||||
|
||||
### 重构
|
||||
- `baoyu-image-gen`:导出函数以支持测试,新增模块入口守卫
|
||||
|
||||
### 文档
|
||||
- `baoyu-image-gen`:在 SKILL.md 和 README 中添加即梦和豆包服务商文档
|
||||
|
||||
### 测试
|
||||
- 新增测试基础设施,包含 CI 工作流和 image-gen 单元测试
|
||||
|
||||
## 1.65.1 - 2026-03-13
|
||||
|
||||
### 重构
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.65.1**.
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.66.0**.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -665,7 +665,7 @@ AI-powered generation backends.
|
||||
|
||||
#### baoyu-image-gen
|
||||
|
||||
AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
|
||||
```bash
|
||||
# Basic generation (auto-detect provider)
|
||||
@@ -689,6 +689,12 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
# Replicate
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider replicate
|
||||
|
||||
# Jimeng (即梦)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# Seedream (豆包)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
||||
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
||||
```
|
||||
@@ -699,7 +705,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `--prompt`, `-p` | Prompt text |
|
||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||
| `--image` | Output image path (required) |
|
||||
| `--provider` | `google`, `openai`, `openrouter`, `dashscope` or `replicate` (default: auto-detect; prefers google) |
|
||||
| `--provider` | `google`, `openai`, `openrouter`, `dashscope`, `jimeng`, `seedream` or `replicate` (default: auto-detect; prefers google) |
|
||||
| `--model`, `-m` | Model ID |
|
||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size` | Size (e.g., `1024x1024`) |
|
||||
@@ -714,16 +720,24 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `GOOGLE_API_KEY` | Google API key | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng Volcengine secret key | - |
|
||||
| `ARK_API_KEY` | Seedream Volcengine ARK API key | - |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint | `https://openrouter.ai/api/v1` |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | Jimeng region | `cn-north-1` |
|
||||
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
|
||||
**Provider Auto-Selection**:
|
||||
1. If `--provider` specified → use it
|
||||
@@ -989,6 +1003,18 @@ DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# Jimeng (即梦)
|
||||
JIMENG_ACCESS_KEY_ID=xxx
|
||||
JIMENG_SECRET_ACCESS_KEY=xxx
|
||||
JIMENG_IMAGE_MODEL=jimeng_t2i_v40
|
||||
# JIMENG_BASE_URL=https://visual.volcengineapi.com
|
||||
# JIMENG_REGION=cn-north-1
|
||||
|
||||
# Seedream (豆包)
|
||||
ARK_API_KEY=xxx
|
||||
SEEDREAM_IMAGE_MODEL=doubao-seedream-5-0-260128
|
||||
# SEEDREAM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
+28
-2
@@ -665,7 +665,7 @@ AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-image-gen
|
||||
|
||||
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
||||
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
||||
|
||||
```bash
|
||||
# 基础生成(自动检测服务商)
|
||||
@@ -689,6 +689,12 @@ AI 驱动的生成后端。
|
||||
# Replicate
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
|
||||
|
||||
# 即梦(Jimeng)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# 豆包(Seedream)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# 带参考图(Google、OpenAI、OpenRouter 或 Replicate)
|
||||
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||
```
|
||||
@@ -699,7 +705,7 @@ AI 驱动的生成后端。
|
||||
| `--prompt`, `-p` | 提示词文本 |
|
||||
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
||||
| `--image` | 输出图片路径(必需) |
|
||||
| `--provider` | `google`、`openai`、`openrouter`、`dashscope` 或 `replicate`(默认:自动检测,优先 google) |
|
||||
| `--provider` | `google`、`openai`、`openrouter`、`dashscope`、`jimeng`、`seedream` 或 `replicate`(默认:自动检测,优先 google) |
|
||||
| `--model`, `-m` | 模型 ID |
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`) |
|
||||
@@ -714,16 +720,24 @@ AI 驱动的生成后端。
|
||||
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API Token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | 即梦火山引擎 Secret Key | - |
|
||||
| `ARK_API_KEY` | 豆包火山引擎 ARK API 密钥 | - |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||
| `OPENROUTER_BASE_URL` | 自定义 OpenRouter 端点 | `https://openrouter.ai/api/v1` |
|
||||
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
|
||||
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | 即梦区域 | `cn-north-1` |
|
||||
| `SEEDREAM_BASE_URL` | 自定义豆包端点 | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
@@ -989,6 +1003,18 @@ DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# 即梦(Jimeng)
|
||||
JIMENG_ACCESS_KEY_ID=xxx
|
||||
JIMENG_SECRET_ACCESS_KEY=xxx
|
||||
JIMENG_IMAGE_MODEL=jimeng_t2i_v40
|
||||
# JIMENG_BASE_URL=https://visual.volcengineapi.com
|
||||
# JIMENG_REGION=cn-north-1
|
||||
|
||||
# 豆包(Seedream)
|
||||
ARK_API_KEY=xxx
|
||||
SEEDREAM_IMAGE_MODEL=doubao-seedream-5-0-260128
|
||||
# SEEDREAM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Testing Strategy
|
||||
|
||||
This repository has many scripts, but they do not share a single runtime or dependency graph. The lowest-risk testing strategy is to start from stable Node-based library code, then expand outward to CLI and skill-specific smoke tests.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Root test runner: `node:test`
|
||||
- Entry point: `npm test`
|
||||
- Coverage command: `npm run test:coverage`
|
||||
- CI trigger: GitHub Actions on `push`, `pull_request`, and manual dispatch
|
||||
|
||||
This avoids introducing Jest/Vitest across a repo that already mixes plain Node scripts, Bun-based skill packages, vendored code, and browser automation.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1: Stable library coverage
|
||||
|
||||
Focus on pure functions under `scripts/lib/` first.
|
||||
|
||||
- `scripts/lib/release-files.mjs`
|
||||
- `scripts/lib/shared-skill-packages.mjs`
|
||||
|
||||
Goals:
|
||||
|
||||
- Validate file filtering and release packaging rules
|
||||
- Catch regressions in package vendoring and dependency rewriting
|
||||
- Keep tests deterministic and free of network, Bun, or browser requirements
|
||||
|
||||
### Phase 2: Root CLI integration tests
|
||||
|
||||
Add temp-directory integration tests for root CLIs that already support dry-run or local-only flows.
|
||||
|
||||
- `scripts/sync-shared-skill-packages.mjs`
|
||||
- `scripts/publish-skill.mjs --dry-run`
|
||||
- `scripts/sync-clawhub.mjs` argument handling and local skill discovery
|
||||
|
||||
Goals:
|
||||
|
||||
- Assert exit codes and stdout for common flows
|
||||
- Cover CLI argument parsing without hitting external services
|
||||
|
||||
### Phase 3: Skill script smoke tests
|
||||
|
||||
Add opt-in smoke tests for selected `skills/*/scripts/` packages, starting with those that:
|
||||
|
||||
- accept local input files
|
||||
- have deterministic output
|
||||
- do not require authenticated browser sessions
|
||||
|
||||
Examples:
|
||||
|
||||
- markdown transforms
|
||||
- file conversion helpers
|
||||
- local content analyzers
|
||||
|
||||
Keep browser automation, login flows, and live API publishing scripts outside the default CI path unless they are explicitly mocked.
|
||||
|
||||
### Phase 4: Coverage gates
|
||||
|
||||
After the stable Node path has enough breadth, add coverage thresholds in CI for the tested root modules.
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Start with reporting only
|
||||
2. Add line/function thresholds for `scripts/lib/**`
|
||||
3. Expand include patterns once skill-level smoke tests are reliable
|
||||
|
||||
## Conventions For New Tests
|
||||
|
||||
- Prefer temp directories over committed fixtures unless the fixture is reused heavily
|
||||
- Test exported functions before testing CLI wrappers
|
||||
- Avoid network, browser, and credential dependencies in default CI
|
||||
- Keep tests isolated so they can run with plain `node --test`
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "baoyu-skills",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"test:coverage": "node --experimental-test-coverage --test"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-image-gen
|
||||
description: AI image generation with OpenAI, Google, OpenRouter, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||
description: AI image generation with OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||
version: 1.56.2
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -13,7 +13,7 @@ metadata:
|
||||
|
||||
# Image Generation (AI SDK)
|
||||
|
||||
Official API-based image generation. Supports OpenAI, Google, OpenRouter, DashScope (阿里通义万象) and Replicate providers.
|
||||
Official API-based image generation. Supports OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||
|
||||
## Script Directory
|
||||
|
||||
@@ -141,13 +141,13 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `--image <path>` | Output image path (required in single-image mode) |
|
||||
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||
| `--provider google\|openai\|openrouter\|dashscope\|replicate` | Force provider (default: auto-detect) |
|
||||
| `--provider google\|openai\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`) |
|
||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, and Replicate |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, and Replicate. Not supported by Jimeng or Seedream |
|
||||
| `--n <count>` | Number of images |
|
||||
| `--json` | JSON output |
|
||||
|
||||
@@ -160,11 +160,16 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `GOOGLE_API_KEY` | Google API key |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
|
||||
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `https://openrouter.ai/api/v1`) |
|
||||
| `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution |
|
||||
@@ -172,6 +177,9 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
||||
| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) |
|
||||
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint (default: `https://ark.cn-beijing.volces.com/api/v3`) |
|
||||
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency, e.g. `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY` |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider start gap, e.g. `BAOYU_IMAGE_GEN_REPLICATE_START_INTERVAL_MS` |
|
||||
@@ -227,7 +235,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
||||
|
||||
## Provider Selection
|
||||
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then OpenRouter, then Replicate
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then OpenRouter, then Replicate (Jimeng and Seedream do not support reference images)
|
||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `openrouter`, or `replicate`)
|
||||
3. Only one API key available → use that provider
|
||||
4. Multiple available → default to Google
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type {
|
||||
BatchFile,
|
||||
@@ -55,6 +56,8 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
||||
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
openrouter: { concurrency: 3, startIntervalMs: 1100 },
|
||||
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
||||
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
||||
};
|
||||
|
||||
function printUsage(): void {
|
||||
@@ -69,7 +72,7 @@ Options:
|
||||
--image <path> Output image path (required in single-image mode)
|
||||
--batchfile <path> JSON batch file for multi-image generation
|
||||
--jobs <count> Worker count for batch mode (default: auto, max from config, built-in default 10)
|
||||
--provider google|openai|openrouter|dashscope|replicate Force provider (auto-detect by default)
|
||||
--provider google|openai|openrouter|dashscope|replicate|jimeng|seedream Force provider (auto-detect by default)
|
||||
-m, --model <id> Model ID
|
||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
@@ -107,11 +110,16 @@ Environment variables:
|
||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope API key
|
||||
REPLICATE_API_TOKEN Replicate API token
|
||||
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
|
||||
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
|
||||
ARK_API_KEY Seedream/Ark API key
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
|
||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
||||
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
||||
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
|
||||
OPENROUTER_BASE_URL Custom OpenRouter endpoint
|
||||
@@ -120,6 +128,8 @@ Environment variables:
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||
JIMENG_BASE_URL Custom Jimeng endpoint
|
||||
SEEDREAM_BASE_URL Custom Seedream endpoint
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
|
||||
@@ -127,7 +137,7 @@ Environment variables:
|
||||
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
@@ -217,7 +227,9 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
v !== "openai" &&
|
||||
v !== "openrouter" &&
|
||||
v !== "dashscope" &&
|
||||
v !== "replicate"
|
||||
v !== "replicate" &&
|
||||
v !== "jimeng" &&
|
||||
v !== "seedream"
|
||||
) {
|
||||
throw new Error(`Invalid provider: ${v}`);
|
||||
}
|
||||
@@ -327,12 +339,12 @@ async function loadEnv(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function extractYamlFrontMatter(content: string): string | null {
|
||||
export function extractYamlFrontMatter(content: string): string | null {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
const config: Partial<ExtendConfig> = {};
|
||||
const lines = yaml.split("\n");
|
||||
let currentKey: string | null = null;
|
||||
@@ -370,6 +382,8 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
openrouter: null,
|
||||
dashscope: null,
|
||||
replicate: null,
|
||||
jimeng: null,
|
||||
seedream: null,
|
||||
};
|
||||
currentKey = "default_model";
|
||||
currentProvider = null;
|
||||
@@ -393,7 +407,9 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "replicate"
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream"
|
||||
)
|
||||
) {
|
||||
config.batch ??= {};
|
||||
@@ -407,7 +423,9 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "replicate"
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream"
|
||||
)
|
||||
) {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
@@ -456,7 +474,7 @@ async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
export function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
return {
|
||||
...args,
|
||||
provider: args.provider ?? extend.default_provider ?? null,
|
||||
@@ -466,13 +484,13 @@ function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined): number | null {
|
||||
export function parsePositiveInt(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function parsePositiveBatchInt(value: unknown): number | null {
|
||||
export function parsePositiveBatchInt(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
@@ -483,13 +501,13 @@ function parsePositiveBatchInt(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConfiguredMaxWorkers(extendConfig: Partial<ExtendConfig>): number {
|
||||
export function getConfiguredMaxWorkers(extendConfig: Partial<ExtendConfig>): number {
|
||||
const envValue = parsePositiveInt(process.env.BAOYU_IMAGE_GEN_MAX_WORKERS);
|
||||
const configValue = extendConfig.batch?.max_workers ?? null;
|
||||
return Math.max(1, envValue ?? configValue ?? DEFAULT_MAX_WORKERS);
|
||||
}
|
||||
|
||||
function getConfiguredProviderRateLimits(
|
||||
export function getConfiguredProviderRateLimits(
|
||||
extendConfig: Partial<ExtendConfig>
|
||||
): Record<Provider, ProviderRateLimit> {
|
||||
const configured: Record<Provider, ProviderRateLimit> = {
|
||||
@@ -498,9 +516,11 @@ function getConfiguredProviderRateLimits(
|
||||
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
|
||||
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
||||
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
||||
};
|
||||
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
||||
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||
configured[provider] = {
|
||||
@@ -540,14 +560,14 @@ async function readPromptFromStdin(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutputImagePath(p: string): string {
|
||||
export function normalizeOutputImagePath(p: string): string {
|
||||
const full = path.resolve(p);
|
||||
const ext = path.extname(full);
|
||||
if (ext) return full;
|
||||
return `${full}.png`;
|
||||
}
|
||||
|
||||
function detectProvider(args: CliArgs): Provider {
|
||||
export function detectProvider(args: CliArgs): Provider {
|
||||
if (
|
||||
args.referenceImages.length > 0 &&
|
||||
args.provider &&
|
||||
@@ -568,6 +588,8 @@ function detectProvider(args: CliArgs): Provider {
|
||||
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
|
||||
const hasSeedream = !!process.env.ARK_API_KEY;
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
@@ -575,7 +597,7 @@ function detectProvider(args: CliArgs): Provider {
|
||||
if (hasOpenrouter) return "openrouter";
|
||||
if (hasReplicate) return "replicate";
|
||||
throw new Error(
|
||||
"Reference images require Google, OpenAI, OpenRouter or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
"Reference images require Google, OpenAI, OpenRouter, or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -585,18 +607,20 @@ function detectProvider(args: CliArgs): Provider {
|
||||
hasOpenrouter && "openrouter",
|
||||
hasDashscope && "dashscope",
|
||||
hasReplicate && "replicate",
|
||||
hasJimeng && "jimeng",
|
||||
hasSeedream && "seedream",
|
||||
].filter(Boolean) as Provider[];
|
||||
|
||||
if (available.length === 1) return available[0]!;
|
||||
if (available.length > 1) return available[0]!;
|
||||
|
||||
throw new Error(
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, or REPLICATE_API_TOKEN.\n" +
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
|
||||
async function validateReferenceImages(referenceImages: string[]): Promise<void> {
|
||||
export async function validateReferenceImages(referenceImages: string[]): Promise<void> {
|
||||
for (const refPath of referenceImages) {
|
||||
const fullPath = path.resolve(refPath);
|
||||
try {
|
||||
@@ -607,7 +631,7 @@ async function validateReferenceImages(referenceImages: string[]): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableGenerationError(error: unknown): boolean {
|
||||
export function isRetryableGenerationError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const nonRetryableMarkers = [
|
||||
"Reference image",
|
||||
@@ -632,6 +656,8 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
||||
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
||||
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
|
||||
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
|
||||
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
|
||||
return (await import("./providers/openai")) as ProviderModule;
|
||||
}
|
||||
|
||||
@@ -658,6 +684,8 @@ function getModelForProvider(
|
||||
}
|
||||
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
||||
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
|
||||
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
|
||||
}
|
||||
return providerModule.getDefaultModel();
|
||||
}
|
||||
@@ -685,7 +713,7 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
||||
};
|
||||
}
|
||||
|
||||
async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks> {
|
||||
export async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks> {
|
||||
const resolvedBatchFilePath = path.resolve(batchFilePath);
|
||||
const content = await readFile(resolvedBatchFilePath, "utf8");
|
||||
const parsed = JSON.parse(content.replace(/^\uFEFF/, "")) as BatchFile;
|
||||
@@ -711,11 +739,11 @@ async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks>
|
||||
throw new Error("Invalid batch file. Expected an array of tasks or an object with a tasks array.");
|
||||
}
|
||||
|
||||
function resolveBatchPath(batchDir: string, filePath: string): string {
|
||||
export function resolveBatchPath(batchDir: string, filePath: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);
|
||||
}
|
||||
|
||||
function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
|
||||
export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
|
||||
return {
|
||||
...baseArgs,
|
||||
prompt: task.prompt ?? null,
|
||||
@@ -854,7 +882,7 @@ function createProviderGate(providerRateLimits: Record<Provider, ProviderRateLim
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {
|
||||
export function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {
|
||||
const requested = jobs ?? Math.min(taskCount, maxWorkers);
|
||||
return Math.max(1, Math.min(requested, taskCount, maxWorkers));
|
||||
}
|
||||
@@ -873,7 +901,7 @@ async function runBatchTasks(
|
||||
const acquireProvider = createProviderGate(providerRateLimits);
|
||||
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
||||
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
||||
for (const provider of ["replicate", "google", "openai", "dashscope"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
||||
const limit = providerRateLimits[provider];
|
||||
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||
}
|
||||
@@ -984,8 +1012,21 @@ async function main(): Promise<void> {
|
||||
await runSingleMode(mergedArgs, extendConfig);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
function isDirectExecution(metaUrl: string): boolean {
|
||||
const entryPath = process.argv[1];
|
||||
if (!entryPath) return false;
|
||||
|
||||
try {
|
||||
return path.resolve(entryPath) === fileURLToPath(metaUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectExecution(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ function getBaseUrl(): string {
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
@@ -45,7 +45,7 @@ const STANDARD_SIZES_2K: [number, number][] = [
|
||||
[2048, 2048],
|
||||
];
|
||||
|
||||
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const is2k = quality === "2k";
|
||||
const defaultSize = is2k ? "1536*1536" : "1024*1024";
|
||||
|
||||
@@ -71,7 +71,7 @@ function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]):
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalizeSize(size: string): string {
|
||||
export function normalizeSize(size: string): string {
|
||||
return size.replace("x", "*");
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,16 @@ export function getDefaultModel(): string {
|
||||
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
|
||||
}
|
||||
|
||||
function normalizeGoogleModelId(model: string): string {
|
||||
export function normalizeGoogleModelId(model: string): string {
|
||||
return model.startsWith("models/") ? model.slice("models/".length) : model;
|
||||
}
|
||||
|
||||
function isGoogleMultimodal(model: string): boolean {
|
||||
export function isGoogleMultimodal(model: string): boolean {
|
||||
const normalized = normalizeGoogleModelId(model);
|
||||
return GOOGLE_MULTIMODAL_MODELS.some((m) => normalized.includes(m));
|
||||
}
|
||||
|
||||
function isGoogleImagen(model: string): boolean {
|
||||
export function isGoogleImagen(model: string): boolean {
|
||||
const normalized = normalizeGoogleModelId(model);
|
||||
return GOOGLE_IMAGEN_MODELS.some((m) => normalized.includes(m));
|
||||
}
|
||||
@@ -35,7 +35,7 @@ function getGoogleApiKey(): string | null {
|
||||
return process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || null;
|
||||
}
|
||||
|
||||
function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
||||
export function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
||||
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
||||
return args.quality === "2k" ? "2K" : "1K";
|
||||
}
|
||||
@@ -46,7 +46,7 @@ function getGoogleBaseUrl(): string {
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function buildGoogleUrl(pathname: string): string {
|
||||
export function buildGoogleUrl(pathname: string): string {
|
||||
const base = getGoogleBaseUrl();
|
||||
const cleanedPath = pathname.replace(/^\/+/g, "");
|
||||
if (base.endsWith("/v1beta")) return `${base}/${cleanedPath}`;
|
||||
@@ -162,7 +162,7 @@ async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
|
||||
return postGoogleJsonViaFetch<T>(url, apiKey, body);
|
||||
}
|
||||
|
||||
function buildPromptWithAspect(
|
||||
export function buildPromptWithAspect(
|
||||
prompt: string,
|
||||
ar: string | null,
|
||||
quality: CliArgs["quality"],
|
||||
@@ -177,7 +177,7 @@ function buildPromptWithAspect(
|
||||
return result;
|
||||
}
|
||||
|
||||
function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
|
||||
export function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
|
||||
if (!ar) return prompt;
|
||||
return `${prompt} Aspect ratio: ${ar}.`;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ async function readImageAsBase64(
|
||||
return { data: buf.toString("base64"), mimeType };
|
||||
}
|
||||
|
||||
function extractInlineImageData(response: {
|
||||
export function extractInlineImageData(response: {
|
||||
candidates?: Array<{
|
||||
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
|
||||
}>;
|
||||
@@ -208,7 +208,7 @@ function extractInlineImageData(response: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPredictedImageData(response: {
|
||||
export function extractPredictedImageData(response: {
|
||||
predictions?: Array<any>;
|
||||
generatedImages?: Array<any>;
|
||||
}): string | null {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { CliArgs } from "../types";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
type JimengSizePreset = "normal" | "2k" | "4k";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40";
|
||||
}
|
||||
|
||||
function getAccessKey(): string | null {
|
||||
return process.env.JIMENG_ACCESS_KEY_ID || null;
|
||||
}
|
||||
|
||||
function getSecretKey(): string | null {
|
||||
return process.env.JIMENG_SECRET_ACCESS_KEY || null;
|
||||
}
|
||||
|
||||
function getRegion(): string {
|
||||
return process.env.JIMENG_REGION || "cn-north-1";
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.JIMENG_BASE_URL || "https://visual.volcengineapi.com";
|
||||
}
|
||||
|
||||
function resolveEndpoint(query: Record<string, string>): {
|
||||
url: string;
|
||||
host: string;
|
||||
canonicalUri: string;
|
||||
} {
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(getBaseUrl());
|
||||
} catch {
|
||||
throw new Error(`Invalid JIMENG_BASE_URL: ${getBaseUrl()}`);
|
||||
}
|
||||
|
||||
baseUrl.search = "";
|
||||
for (const [key, value] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
baseUrl.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
return {
|
||||
url: baseUrl.toString(),
|
||||
host: baseUrl.host,
|
||||
canonicalUri: baseUrl.pathname || "/",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Volcengine HMAC-SHA256 signature generation
|
||||
* Following the official documentation at:
|
||||
* https://www.volcengine.com/docs/85621/1817045
|
||||
*/
|
||||
function generateSignature(
|
||||
method: string,
|
||||
query: Record<string, string>,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string,
|
||||
service: string,
|
||||
canonicalUri: string
|
||||
): string {
|
||||
// 1. Create canonical request
|
||||
// Sort query parameters alphabetically
|
||||
const sortedQuery = Object.entries(query)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
|
||||
// Sort headers alphabetically and create canonical headers
|
||||
const sortedHeaders = Object.entries(headers)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k.toLowerCase()}:${v.trim()}\n`)
|
||||
.join("");
|
||||
|
||||
const signedHeaders = Object.keys(headers)
|
||||
.sort()
|
||||
.map(k => k.toLowerCase())
|
||||
.join(";");
|
||||
|
||||
const hashedPayload = crypto.createHash("sha256").update(body, "utf8").digest("hex");
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
sortedQuery,
|
||||
sortedHeaders,
|
||||
signedHeaders,
|
||||
hashedPayload,
|
||||
].join("\n");
|
||||
|
||||
const hashedCanonicalRequest = crypto
|
||||
.createHash("sha256")
|
||||
.update(canonicalRequest, "utf8")
|
||||
.digest("hex");
|
||||
|
||||
// 2. Create string to sign
|
||||
const algorithm = "HMAC-SHA256";
|
||||
const timestamp = headers["X-Date"] || headers["x-date"];
|
||||
if (!timestamp) {
|
||||
throw new Error("Jimeng signature generation requires an X-Date header.");
|
||||
}
|
||||
const dateStamp = timestamp.slice(0, 8);
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/request`;
|
||||
|
||||
const stringToSign = [
|
||||
algorithm,
|
||||
timestamp,
|
||||
credentialScope,
|
||||
hashedCanonicalRequest,
|
||||
].join("\n");
|
||||
|
||||
// 3. Calculate signature
|
||||
const kDate = crypto
|
||||
.createHmac("sha256", secretKey)
|
||||
.update(dateStamp)
|
||||
.digest();
|
||||
|
||||
const kRegion = crypto.createHmac("sha256", kDate).update(region).digest();
|
||||
const kService = crypto.createHmac("sha256", kRegion).update(service).digest();
|
||||
const kSigning = crypto.createHmac("sha256", kService).update("request").digest();
|
||||
|
||||
const signature = crypto
|
||||
.createHmac("sha256", kSigning)
|
||||
.update(stringToSign)
|
||||
.digest("hex");
|
||||
|
||||
// 4. Create authorization header
|
||||
return `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse aspect ratio string like "16:9", "1:1", "4:3" into width and height
|
||||
*/
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
const h = parseFloat(match[2]!);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported size presets for different quality levels
|
||||
* Based on Volcengine Jimeng documentation
|
||||
*/
|
||||
const SIZE_PRESETS: Record<string, Record<string, string>> = {
|
||||
normal: {
|
||||
"1:1": "1024x1024",
|
||||
"4:3": "1360x1020",
|
||||
"16:9": "1536x864",
|
||||
"3:2": "1440x960",
|
||||
"21:9": "1920x824",
|
||||
},
|
||||
"2k": {
|
||||
"1:1": "2048x2048",
|
||||
"4:3": "2304x1728",
|
||||
"16:9": "2560x1440",
|
||||
"3:2": "2496x1664",
|
||||
"21:9": "3024x1296",
|
||||
},
|
||||
"4k": {
|
||||
"1:1": "4096x4096",
|
||||
"4:3": "4694x3520",
|
||||
"16:9": "5404x3040",
|
||||
"3:2": "4992x3328",
|
||||
"21:9": "6198x2656",
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeDimensions(value: string): string | null {
|
||||
const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
return `${match[1]}x${match[2]}`;
|
||||
}
|
||||
|
||||
function getClosestPresetSize(ar: string | null, qualityLevel: JimengSizePreset): string {
|
||||
const presets = SIZE_PRESETS[qualityLevel];
|
||||
const defaultSize = presets["1:1"]!;
|
||||
|
||||
if (!ar) return defaultSize;
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return defaultSize;
|
||||
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
let bestMatch = defaultSize;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [ratio, size] of Object.entries(presets)) {
|
||||
const [w, h] = ratio.split(":").map(Number);
|
||||
const presetRatio = w / h;
|
||||
const diff = Math.abs(presetRatio - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestMatch = size;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function normalizeImageSizePreset(imageSize: string, ar: string | null): string | null {
|
||||
const preset = imageSize.trim().toUpperCase();
|
||||
if (preset === "1K") return getClosestPresetSize(ar, "normal");
|
||||
if (preset === "2K") return getClosestPresetSize(ar, "2k");
|
||||
if (preset === "4K") return getClosestPresetSize(ar, "4k");
|
||||
return normalizeDimensions(imageSize);
|
||||
}
|
||||
|
||||
function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||
if (imageSize) {
|
||||
const normalizedSize = normalizeImageSizePreset(imageSize, ar);
|
||||
if (normalizedSize) return normalizedSize;
|
||||
}
|
||||
|
||||
// Default to 2K quality if not specified
|
||||
const qualityLevel: JimengSizePreset = quality === "normal" ? "normal" : "2k";
|
||||
return getClosestPresetSize(ar, qualityLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Submit async task to Volcengine Jimeng API
|
||||
*/
|
||||
async function submitTask(
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string
|
||||
): Promise<string> {
|
||||
// Query parameters for submit endpoint
|
||||
const query = {
|
||||
Action: "CVSync2AsyncSubmitTask",
|
||||
Version: "2022-08-31",
|
||||
};
|
||||
const endpoint = resolveEndpoint(query);
|
||||
|
||||
// Request body - Jimeng API expects width/height as separate integers
|
||||
const [width, height] = size.split("x").map(Number);
|
||||
const bodyObj = {
|
||||
req_key: model,
|
||||
prompt_text: prompt,
|
||||
// Use separate width and height parameters instead of size string
|
||||
width: width,
|
||||
height: height,
|
||||
// Optional: seed for reproducibility
|
||||
// seed: Math.floor(Math.random() * 999999),
|
||||
};
|
||||
|
||||
const body = JSON.stringify(bodyObj);
|
||||
|
||||
// Headers
|
||||
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Date": timestampHeader,
|
||||
"Host": endpoint.host,
|
||||
};
|
||||
|
||||
// Generate signature
|
||||
const authorization = generateSignature(
|
||||
"POST",
|
||||
query,
|
||||
headers,
|
||||
body,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
"cv",
|
||||
endpoint.canonicalUri
|
||||
);
|
||||
|
||||
console.error(`Submitting task to Jimeng (${model})...`, { width, height });
|
||||
|
||||
const res = await fetch(endpoint.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Authorization": authorization,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Jimeng API submit error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
task_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Volcengine API returns code 10000 for success
|
||||
if (result.code !== 10000 || !result.data?.task_id) {
|
||||
console.error("Submit response:", JSON.stringify(result, null, 2));
|
||||
throw new Error(`Failed to submit task: ${result.message || "Unknown error"}`);
|
||||
}
|
||||
|
||||
return result.data.task_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Poll for task result
|
||||
* Returns image data directly as Uint8Array
|
||||
*/
|
||||
async function pollForResult(
|
||||
taskId: string,
|
||||
model: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string
|
||||
): Promise<Uint8Array> {
|
||||
const maxAttempts = 60;
|
||||
const pollIntervalMs = 2000;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// Query parameters for result endpoint
|
||||
const query = {
|
||||
Action: "CVSync2AsyncGetResult",
|
||||
Version: "2022-08-31",
|
||||
};
|
||||
const endpoint = resolveEndpoint(query);
|
||||
|
||||
// Request body - include req_key and task_id
|
||||
const bodyObj = {
|
||||
req_key: model,
|
||||
task_id: taskId,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(bodyObj);
|
||||
|
||||
// Headers
|
||||
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Date": timestampHeader,
|
||||
"Host": endpoint.host,
|
||||
};
|
||||
|
||||
// Generate signature
|
||||
const authorization = generateSignature(
|
||||
"POST",
|
||||
query,
|
||||
headers,
|
||||
body,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
"cv",
|
||||
endpoint.canonicalUri
|
||||
);
|
||||
|
||||
const res = await fetch(endpoint.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Authorization": authorization,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Jimeng API poll error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
status?: string;
|
||||
image_urls?: string[];
|
||||
binary_data_base64?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
// Volcengine API returns code 10000 for success
|
||||
if (result.code === 10000 && result.data) {
|
||||
const { status, image_urls, binary_data_base64 } = result.data;
|
||||
|
||||
// Check for base64 image data (preferred by Jimeng)
|
||||
if (binary_data_base64 && binary_data_base64.length > 0) {
|
||||
console.error("Image received as base64 data");
|
||||
const base64Data = binary_data_base64[0]!;
|
||||
// Convert base64 to Uint8Array
|
||||
const binaryString = Buffer.from(base64Data, "base64").toString("binary");
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Fallback to URL format
|
||||
if (status === "done" && image_urls && image_urls.length > 0) {
|
||||
// Download from URL
|
||||
console.error(`Downloading image from ${image_urls[0]}...`);
|
||||
const imgRes = await fetch(image_urls[0]!);
|
||||
if (!imgRes.ok) {
|
||||
throw new Error(`Failed to download image from ${image_urls[0]}`);
|
||||
}
|
||||
const buffer = await imgRes.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
if (status === "in_queue" || status === "generating") {
|
||||
console.error(`Task status: ${status} (${attempt + 1}/${maxAttempts})`);
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === "fail") {
|
||||
throw new Error(`Jimeng task failed: ${result.message || "Generation failed"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Poll response:", JSON.stringify(result, null, 2));
|
||||
throw new Error(`Unexpected response during polling: ${result.message || "Unknown error"}`);
|
||||
}
|
||||
|
||||
throw new Error("Task timeout: image generation took too long");
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
if (args.referenceImages.length > 0) {
|
||||
throw new Error(
|
||||
"Jimeng does not support reference images. Use --provider google, openai, openrouter, or replicate."
|
||||
);
|
||||
}
|
||||
|
||||
const accessKey = getAccessKey();
|
||||
const secretKey = getSecretKey();
|
||||
const region = getRegion();
|
||||
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error(
|
||||
"JIMENG_ACCESS_KEY_ID and JIMENG_SECRET_ACCESS_KEY are required. " +
|
||||
"Get your credentials from https://console.volcengine.com/iam/keymanage"
|
||||
);
|
||||
}
|
||||
|
||||
const size = getImageSize(args.aspectRatio, args.quality, args.imageSize);
|
||||
|
||||
// Step 1: Submit task
|
||||
const taskId = await submitTask(prompt, model, size, accessKey, secretKey, region);
|
||||
|
||||
// Step 2: Poll for result (returns image data directly)
|
||||
const imageData = await pollForResult(taskId, model, accessKey, secretKey, region);
|
||||
|
||||
console.error("Image generation complete!");
|
||||
return imageData;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export function getDefaultModel(): string {
|
||||
|
||||
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
@@ -23,7 +23,7 @@ type SizeMapping = {
|
||||
portrait: string;
|
||||
};
|
||||
|
||||
function getOpenAISize(
|
||||
export function getOpenAISize(
|
||||
model: string,
|
||||
ar: string | null,
|
||||
quality: CliArgs["quality"]
|
||||
@@ -201,7 +201,7 @@ async function generateWithOpenAIEdits(
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
export function getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
@@ -209,7 +209,7 @@ function getMimeType(filename: string): string {
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
|
||||
export async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
|
||||
const img = result.data[0];
|
||||
|
||||
if (img?.b64_json) {
|
||||
|
||||
@@ -20,7 +20,7 @@ function getBaseUrl(): string {
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
export function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
const [ownerName, version] = model.split(":");
|
||||
const parts = ownerName!.split("/");
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
@@ -31,7 +31,7 @@ function parseModelId(model: string): { owner: string; name: string; version: st
|
||||
return { owner: parts[0], name: parts[1], version: version || null };
|
||||
}
|
||||
|
||||
function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
export function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = { prompt };
|
||||
|
||||
if (args.aspectRatio) {
|
||||
@@ -144,7 +144,7 @@ async function pollPrediction(apiToken: string, getUrl: string): Promise<Predict
|
||||
throw new Error(`Replicate prediction timed out after ${MAX_POLL_MS / 1000}s`);
|
||||
}
|
||||
|
||||
function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
export function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
const output = prediction.output;
|
||||
|
||||
if (typeof output === "string") return output;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.ARK_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert aspect ratio to Seedream size format
|
||||
* Seedream API accepts: "2k" (default), "3k", or WIDTHxHEIGHT format
|
||||
* Note: API uses lowercase "2k"/"3k", not "2K"/"3K"
|
||||
*/
|
||||
function getSeedreamSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||
// If explicit size is provided
|
||||
if (imageSize) {
|
||||
const upper = imageSize.toUpperCase();
|
||||
if (upper === "2K" || upper === "3K") {
|
||||
return upper.toLowerCase(); // API expects "2k" or "3k"
|
||||
}
|
||||
// For widthxheight format, pass through as-is
|
||||
if (imageSize.includes("x")) {
|
||||
return imageSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 2k (smallest option supported by API)
|
||||
return "2k";
|
||||
}
|
||||
|
||||
type SeedreamImageResponse = {
|
||||
model: string;
|
||||
created: number;
|
||||
data: Array<{
|
||||
url: string;
|
||||
size: string;
|
||||
}>;
|
||||
usage: {
|
||||
generated_images: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"ARK_API_KEY is required. " +
|
||||
"Get your API key from https://console.volcengine.com/ark"
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const size = getSeedreamSize(args.aspectRatio, args.quality, args.imageSize);
|
||||
|
||||
console.error(`Calling Seedream API (${model}) with size: ${size}`);
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
output_format: "png",
|
||||
watermark: false,
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Seedream API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as SeedreamImageResponse;
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
throw new Error("No image data in Seedream response");
|
||||
}
|
||||
|
||||
const imageUrl = result.data[0].url;
|
||||
if (!imageUrl) {
|
||||
throw new Error("No image URL in Seedream response");
|
||||
}
|
||||
|
||||
// Download image from URL
|
||||
console.error(`Downloading image from ${imageUrl}...`);
|
||||
const imgResponse = await fetch(imageUrl);
|
||||
if (!imgResponse.ok) {
|
||||
throw new Error(`Failed to download image from ${imageUrl}`);
|
||||
}
|
||||
|
||||
const buffer = await imgResponse.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate";
|
||||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate" | "jimeng" | "seedream";
|
||||
export type Quality = "normal" | "2k";
|
||||
|
||||
export type CliArgs = {
|
||||
@@ -53,6 +53,8 @@ export type ExtendConfig = {
|
||||
openrouter: string | null;
|
||||
dashscope: string | null;
|
||||
replicate: string | null;
|
||||
jimeng: string | null;
|
||||
seedream: string | null;
|
||||
};
|
||||
batch?: {
|
||||
max_workers?: number | null;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
listReleaseFiles,
|
||||
validateSelfContainedRelease,
|
||||
} from "../scripts/lib/release-files.mjs";
|
||||
|
||||
async function makeTempDir(prefix) {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function writeFile(filePath, contents = "") {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents);
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("listReleaseFiles skips generated paths and returns sorted relative paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-files-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeFile(path.join(root, "b.txt"), "b");
|
||||
await writeFile(path.join(root, "a.txt"), "a");
|
||||
await writeFile(path.join(root, "nested", "keep.txt"), "keep");
|
||||
await writeFile(path.join(root, "node_modules", "skip.js"), "skip");
|
||||
await writeFile(path.join(root, ".git", "config"), "skip");
|
||||
await writeFile(path.join(root, "dist", "artifact.txt"), "skip");
|
||||
await writeFile(path.join(root, "out", "artifact.txt"), "skip");
|
||||
await writeFile(path.join(root, "build", "artifact.txt"), "skip");
|
||||
await writeFile(path.join(root, ".DS_Store"), "skip");
|
||||
await writeFile(path.join(root, "bun.lockb"), "skip");
|
||||
|
||||
const files = await listReleaseFiles(root);
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((file) => file.relPath),
|
||||
["a.txt", "b.txt", "nested/keep.txt"],
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-ok-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeJson(path.join(root, "shared", "package.json"), {
|
||||
name: "shared-package",
|
||||
version: "1.0.0",
|
||||
});
|
||||
await writeFile(path.join(root, "shared", "index.js"), "export const shared = true;\n");
|
||||
await writeJson(path.join(root, "skill", "package.json"), {
|
||||
name: "test-skill",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"shared-package": "file:../shared",
|
||||
},
|
||||
});
|
||||
|
||||
await assert.doesNotReject(() => validateSelfContainedRelease(root));
|
||||
});
|
||||
|
||||
test("validateSelfContainedRelease rejects missing local file dependencies", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-missing-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeJson(path.join(root, "skill", "package.json"), {
|
||||
name: "test-skill",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"shared-package": "file:../shared",
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => validateSelfContainedRelease(root),
|
||||
/Missing local dependency for release/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSelfContainedRelease rejects file dependencies outside the release root", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-root-");
|
||||
const outside = await makeTempDir("baoyu-release-outside-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
t.after(() => fs.rm(outside, { recursive: true, force: true }));
|
||||
|
||||
const skillDir = path.join(root, "skill");
|
||||
const externalSpec = path
|
||||
.relative(skillDir, outside)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
|
||||
await writeJson(path.join(skillDir, "package.json"), {
|
||||
name: "test-skill",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"outside-package": `file:${externalSpec}`,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => validateSelfContainedRelease(root),
|
||||
/Release target is not self-contained/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { syncSharedSkillPackages } from "../scripts/lib/shared-skill-packages.mjs";
|
||||
|
||||
async function makeTempDir(prefix) {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function writeFile(filePath, contents = "") {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents);
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("syncSharedSkillPackages vendors workspace packages into skill scripts", async (t) => {
|
||||
const root = await makeTempDir("baoyu-sync-shared-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeJson(path.join(root, "packages", "baoyu-md", "package.json"), {
|
||||
name: "baoyu-md",
|
||||
version: "1.0.0",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(root, "packages", "baoyu-md", "src", "index.ts"),
|
||||
"export const markdown = true;\n",
|
||||
);
|
||||
|
||||
const consumerDir = path.join(root, "skills", "demo-skill", "scripts");
|
||||
await writeJson(path.join(consumerDir, "package.json"), {
|
||||
name: "demo-skill-scripts",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"baoyu-md": "^1.0.0",
|
||||
kleur: "^4.1.5",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await syncSharedSkillPackages(root, { install: false });
|
||||
|
||||
assert.deepEqual(result.packageDirs, [consumerDir]);
|
||||
assert.deepEqual(result.managedPaths, [
|
||||
"skills/demo-skill/scripts/bun.lock",
|
||||
"skills/demo-skill/scripts/package.json",
|
||||
"skills/demo-skill/scripts/vendor",
|
||||
]);
|
||||
|
||||
const updatedPackageJson = JSON.parse(
|
||||
await fs.readFile(path.join(consumerDir, "package.json"), "utf8"),
|
||||
);
|
||||
assert.equal(updatedPackageJson.dependencies["baoyu-md"], "file:./vendor/baoyu-md");
|
||||
assert.equal(updatedPackageJson.dependencies.kleur, "^4.1.5");
|
||||
|
||||
const vendoredPackageJson = JSON.parse(
|
||||
await fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "package.json"), "utf8"),
|
||||
);
|
||||
assert.equal(vendoredPackageJson.name, "baoyu-md");
|
||||
|
||||
const vendoredFile = await fs.readFile(
|
||||
path.join(consumerDir, "vendor", "baoyu-md", "src", "index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(vendoredFile, /markdown = true/);
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createTaskArgs,
|
||||
detectProvider,
|
||||
getConfiguredMaxWorkers,
|
||||
getConfiguredProviderRateLimits,
|
||||
getWorkerCount,
|
||||
isRetryableGenerationError,
|
||||
loadBatchTasks,
|
||||
mergeConfig,
|
||||
normalizeOutputImagePath,
|
||||
parseArgs,
|
||||
parseSimpleYaml,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/main.ts";
|
||||
|
||||
function makeArgs(overrides = {}) {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function useEnv(t, values) {
|
||||
const previous = new Map();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix) {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("parseArgs parses the main image-gen CLI flags", () => {
|
||||
const args = parseArgs([
|
||||
"--promptfiles",
|
||||
"prompts/system.md",
|
||||
"prompts/content.md",
|
||||
"--image",
|
||||
"out/hero",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--quality",
|
||||
"2k",
|
||||
"--imageSize",
|
||||
"4k",
|
||||
"--ref",
|
||||
"ref/one.png",
|
||||
"ref/two.jpg",
|
||||
"--n",
|
||||
"3",
|
||||
"--jobs",
|
||||
"5",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]);
|
||||
assert.equal(args.imagePath, "out/hero");
|
||||
assert.equal(args.provider, "openai");
|
||||
assert.equal(args.quality, "2k");
|
||||
assert.equal(args.imageSize, "4K");
|
||||
assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]);
|
||||
assert.equal(args.n, 3);
|
||||
assert.equal(args.jobs, 5);
|
||||
assert.equal(args.json, true);
|
||||
});
|
||||
|
||||
test("parseArgs falls back to positional prompt and rejects invalid provider", () => {
|
||||
const positional = parseArgs(["draw", "a", "cat"]);
|
||||
assert.equal(positional.prompt, "draw a cat");
|
||||
|
||||
assert.throws(
|
||||
() => parseArgs(["--provider", "stability"]),
|
||||
/Invalid provider/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSimpleYaml parses nested defaults and provider limits", () => {
|
||||
const yaml = `
|
||||
version: 2
|
||||
default_provider: openrouter
|
||||
default_quality: normal
|
||||
default_aspect_ratio: '16:9'
|
||||
default_image_size: 2K
|
||||
default_model:
|
||||
google: gemini-3-pro-image-preview
|
||||
openai: gpt-image-1.5
|
||||
batch:
|
||||
max_workers: 8
|
||||
provider_limits:
|
||||
google:
|
||||
concurrency: 2
|
||||
start_interval_ms: 900
|
||||
openai:
|
||||
concurrency: 4
|
||||
`;
|
||||
|
||||
const config = parseSimpleYaml(yaml);
|
||||
|
||||
assert.equal(config.version, 2);
|
||||
assert.equal(config.default_provider, "openrouter");
|
||||
assert.equal(config.default_quality, "normal");
|
||||
assert.equal(config.default_aspect_ratio, "16:9");
|
||||
assert.equal(config.default_image_size, "2K");
|
||||
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
|
||||
assert.equal(config.default_model?.openai, "gpt-image-1.5");
|
||||
assert.equal(config.batch?.max_workers, 8);
|
||||
assert.deepEqual(config.batch?.provider_limits?.google, {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 900,
|
||||
});
|
||||
assert.deepEqual(config.batch?.provider_limits?.openai, {
|
||||
concurrency: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("mergeConfig only fills values missing from CLI args", () => {
|
||||
const merged = mergeConfig(
|
||||
makeArgs({
|
||||
provider: "openai",
|
||||
quality: null,
|
||||
aspectRatio: null,
|
||||
imageSize: "4K",
|
||||
}),
|
||||
{
|
||||
default_provider: "google",
|
||||
default_quality: "2k",
|
||||
default_aspect_ratio: "3:2",
|
||||
default_image_size: "2K",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(merged.provider, "openai");
|
||||
assert.equal(merged.quality, "2k");
|
||||
assert.equal(merged.aspectRatio, "3:2");
|
||||
assert.equal(merged.imageSize, "4K");
|
||||
});
|
||||
|
||||
test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => {
|
||||
assert.throws(
|
||||
() =>
|
||||
detectProvider(
|
||||
makeArgs({
|
||||
provider: "dashscope",
|
||||
referenceImages: ["ref.png"],
|
||||
}),
|
||||
),
|
||||
/Reference images require a ref-capable provider/,
|
||||
);
|
||||
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: "google-key",
|
||||
OPENAI_API_KEY: "openai-key",
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: null,
|
||||
});
|
||||
assert.equal(detectProvider(makeArgs()), "google");
|
||||
});
|
||||
|
||||
test("detectProvider selects an available ref-capable provider for reference-image tasks", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: "openai-key",
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: null,
|
||||
});
|
||||
assert.equal(
|
||||
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
|
||||
"openai",
|
||||
);
|
||||
});
|
||||
|
||||
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
|
||||
useEnv(t, {
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
||||
BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5",
|
||||
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
|
||||
});
|
||||
|
||||
const extendConfig = {
|
||||
batch: {
|
||||
max_workers: 7,
|
||||
provider_limits: {
|
||||
google: {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(getConfiguredMaxWorkers(extendConfig), 12);
|
||||
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).google, {
|
||||
concurrency: 5,
|
||||
startIntervalMs: 450,
|
||||
});
|
||||
});
|
||||
|
||||
test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-image-gen-batch-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const batchFile = path.join(root, "jobs", "batch.json");
|
||||
await fs.mkdir(path.dirname(batchFile), { recursive: true });
|
||||
await fs.writeFile(
|
||||
batchFile,
|
||||
JSON.stringify({
|
||||
jobs: 2,
|
||||
tasks: [
|
||||
{
|
||||
id: "hero",
|
||||
promptFiles: ["prompts/hero.md"],
|
||||
image: "out/hero",
|
||||
ref: ["refs/hero.png"],
|
||||
ar: "16:9",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const loaded = await loadBatchTasks(batchFile);
|
||||
assert.equal(loaded.jobs, 2);
|
||||
assert.equal(loaded.batchDir, path.dirname(batchFile));
|
||||
assert.equal(loaded.tasks[0].id, "hero");
|
||||
|
||||
const taskArgs = createTaskArgs(
|
||||
makeArgs({
|
||||
provider: "replicate",
|
||||
quality: "2k",
|
||||
json: true,
|
||||
}),
|
||||
loaded.tasks[0],
|
||||
loaded.batchDir,
|
||||
);
|
||||
|
||||
assert.deepEqual(taskArgs.promptFiles, [
|
||||
path.join(loaded.batchDir, "prompts/hero.md"),
|
||||
]);
|
||||
assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero"));
|
||||
assert.deepEqual(taskArgs.referenceImages, [
|
||||
path.join(loaded.batchDir, "refs/hero.png"),
|
||||
]);
|
||||
assert.equal(taskArgs.provider, "replicate");
|
||||
assert.equal(taskArgs.aspectRatio, "16:9");
|
||||
assert.equal(taskArgs.quality, "2k");
|
||||
assert.equal(taskArgs.json, true);
|
||||
});
|
||||
|
||||
test("path normalization, worker count, and retry classification follow expected rules", () => {
|
||||
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
|
||||
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
|
||||
|
||||
assert.equal(getWorkerCount(8, null, 3), 3);
|
||||
assert.equal(getWorkerCount(2, 6, 5), 2);
|
||||
assert.equal(getWorkerCount(5, 0, 4), 1);
|
||||
|
||||
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
|
||||
assert.equal(isRetryableGenerationError(new Error("socket hang up")), true);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getSizeFromAspectRatio,
|
||||
normalizeSize,
|
||||
parseAspectRatio,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/providers/dashscope.ts";
|
||||
|
||||
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
||||
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
||||
assert.equal(parseAspectRatio("square"), null);
|
||||
assert.equal(parseAspectRatio("-1:2"), null);
|
||||
});
|
||||
|
||||
test("DashScope size selection picks the closest supported size per quality preset", () => {
|
||||
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
|
||||
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
|
||||
});
|
||||
|
||||
test("DashScope size normalization converts WxH into provider format", () => {
|
||||
assert.equal(normalizeSize("1024x1024"), "1024*1024");
|
||||
assert.equal(normalizeSize("2048*1152"), "2048*1152");
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
addAspectRatioToPrompt,
|
||||
buildGoogleUrl,
|
||||
buildPromptWithAspect,
|
||||
extractInlineImageData,
|
||||
extractPredictedImageData,
|
||||
getGoogleImageSize,
|
||||
isGoogleImagen,
|
||||
isGoogleMultimodal,
|
||||
normalizeGoogleModelId,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/providers/google.ts";
|
||||
|
||||
function useEnv(t, values) {
|
||||
const previous = new Map();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Google provider helpers normalize model IDs and select image size defaults", () => {
|
||||
assert.equal(
|
||||
normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"),
|
||||
"gemini-3.1-flash-image-preview",
|
||||
);
|
||||
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
|
||||
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
|
||||
assert.equal(
|
||||
getGoogleImageSize({ imageSize: null, quality: "2k" }),
|
||||
"2K",
|
||||
);
|
||||
assert.equal(
|
||||
getGoogleImageSize({ imageSize: "4K", quality: "normal" }),
|
||||
"4K",
|
||||
);
|
||||
});
|
||||
|
||||
test("Google URL builder appends v1beta when the base URL does not already include it", (t) => {
|
||||
useEnv(t, { GOOGLE_BASE_URL: "https://generativelanguage.googleapis.com" });
|
||||
assert.equal(
|
||||
buildGoogleUrl("models/demo:generateContent"),
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/demo:generateContent",
|
||||
);
|
||||
});
|
||||
|
||||
test("Google URL and prompt helpers preserve existing v1beta paths and aspect hints", (t) => {
|
||||
useEnv(t, { GOOGLE_BASE_URL: "https://example.com/custom/v1beta/" });
|
||||
assert.equal(
|
||||
buildGoogleUrl("/models/demo:predict"),
|
||||
"https://example.com/custom/v1beta/models/demo:predict",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
addAspectRatioToPrompt("A city skyline", "16:9"),
|
||||
"A city skyline Aspect ratio: 16:9.",
|
||||
);
|
||||
assert.equal(
|
||||
buildPromptWithAspect("A city skyline", "16:9", "2k"),
|
||||
"A city skyline Aspect ratio: 16:9. High resolution 2048px.",
|
||||
);
|
||||
});
|
||||
|
||||
test("Google response extractors find inline and predicted image payloads", () => {
|
||||
assert.equal(
|
||||
extractInlineImageData({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ inlineData: { data: "inline-base64" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"inline-base64",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
extractPredictedImageData({
|
||||
predictions: [{ image: { imageBytes: "predicted-base64" } }],
|
||||
}),
|
||||
"predicted-base64",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
extractPredictedImageData({
|
||||
generatedImages: [{ bytesBase64Encoded: "generated-base64" }],
|
||||
}),
|
||||
"generated-base64",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
extractImageFromResponse,
|
||||
getMimeType,
|
||||
getOpenAISize,
|
||||
parseAspectRatio,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/providers/openai.ts";
|
||||
|
||||
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
|
||||
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
|
||||
assert.equal(parseAspectRatio("wide"), null);
|
||||
assert.equal(parseAspectRatio("0:1"), null);
|
||||
|
||||
assert.equal(getOpenAISize("dall-e-3", "16:9", "2k"), "1792x1024");
|
||||
assert.equal(getOpenAISize("dall-e-3", "9:16", "normal"), "1024x1792");
|
||||
assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024");
|
||||
});
|
||||
|
||||
test("OpenAI mime-type detection covers supported reference image extensions", () => {
|
||||
assert.equal(getMimeType("frame.png"), "image/png");
|
||||
assert.equal(getMimeType("frame.jpg"), "image/jpeg");
|
||||
assert.equal(getMimeType("frame.webp"), "image/webp");
|
||||
assert.equal(getMimeType("frame.gif"), "image/gif");
|
||||
});
|
||||
|
||||
test("OpenAI response extraction supports base64 and URL download flows", async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const fromBase64 = await extractImageFromResponse({
|
||||
data: [{ b64_json: Buffer.from("hello").toString("base64") }],
|
||||
});
|
||||
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(Uint8Array.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
|
||||
const fromUrl = await extractImageFromResponse({
|
||||
data: [{ url: "https://example.com/image.png" }],
|
||||
});
|
||||
assert.deepEqual([...fromUrl], [1, 2, 3]);
|
||||
|
||||
await assert.rejects(
|
||||
() => extractImageFromResponse({ data: [{}] }),
|
||||
/No image in response/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildInput,
|
||||
extractOutputUrl,
|
||||
parseModelId,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/providers/replicate.ts";
|
||||
|
||||
function makeArgs(overrides = {}) {
|
||||
return {
|
||||
aspectRatio: null,
|
||||
quality: null,
|
||||
n: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("Replicate model parsing accepts official formats and rejects malformed ones", () => {
|
||||
assert.deepEqual(parseModelId("google/nano-banana-pro"), {
|
||||
owner: "google",
|
||||
name: "nano-banana-pro",
|
||||
version: null,
|
||||
});
|
||||
assert.deepEqual(parseModelId("owner/model:abc123"), {
|
||||
owner: "owner",
|
||||
name: "model",
|
||||
version: "abc123",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => parseModelId("just-a-model-name"),
|
||||
/Invalid Replicate model format/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => {
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"A robot painter",
|
||||
makeArgs({
|
||||
aspectRatio: "16:9",
|
||||
quality: "2k",
|
||||
n: 3,
|
||||
}),
|
||||
["data:image/png;base64,AAAA"],
|
||||
),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "16:9",
|
||||
number_of_images: 3,
|
||||
resolution: "2K",
|
||||
output_format: "png",
|
||||
image_input: ["data:image/png;base64,AAAA"],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "match_input_image",
|
||||
resolution: "1K",
|
||||
output_format: "png",
|
||||
image_input: ["ref"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate output extraction supports string, array, and object URLs", () => {
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: "https://example.com/a.png" }),
|
||||
"https://example.com/a.png",
|
||||
);
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: ["https://example.com/b.png"] }),
|
||||
"https://example.com/b.png",
|
||||
);
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: { url: "https://example.com/c.png" } }),
|
||||
"https://example.com/c.png",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => extractOutputUrl({ output: { invalid: true } }),
|
||||
/Unexpected Replicate output format/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user