mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9977ff520c | |||
| 03bd68d3a4 | |||
| 39792f4360 | |||
| 25b8a7f73d | |||
| e13092ba2a | |||
| acbcf19ba2 | |||
| dcd0f81433 | |||
| c938396efc | |||
| 1ba68c9a9c | |||
| b3f5c0a8aa | |||
| 9c45ede0c7 | |||
| 30d2ac98ce | |||
| bfdd64bd4e | |||
| 434d4857da | |||
| 990fea4f7b | |||
| 517ff566a1 | |||
| 4c9af7d92f | |||
| 46c4859d48 | |||
| f3f886217b | |||
| 4d465d55d0 | |||
| 11d80eeaa9 | |||
| 58ba4579ef | |||
| 67a45a57a0 | |||
| 0b8ac256f4 | |||
| eaa0f1aa11 | |||
| ec5f4ffcc9 | |||
| d206e1674d | |||
| b62ad26098 | |||
| 31b2929d1c | |||
| 7c2253dd3e |
@@ -6,14 +6,14 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.97.1"
|
||||
"version": "1.107.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "baoyu-skills",
|
||||
"description": "Content generation, AI backends, and utility tools for daily work efficiency",
|
||||
"source": "./",
|
||||
"strict": true,
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./skills/baoyu-article-illustrator",
|
||||
"./skills/baoyu-comic",
|
||||
@@ -21,6 +21,7 @@
|
||||
"./skills/baoyu-cover-image",
|
||||
"./skills/baoyu-danger-gemini-web",
|
||||
"./skills/baoyu-danger-x-to-markdown",
|
||||
"./skills/baoyu-diagram",
|
||||
"./skills/baoyu-format-markdown",
|
||||
"./skills/baoyu-imagine",
|
||||
"./skills/baoyu-infographic",
|
||||
|
||||
@@ -154,6 +154,7 @@ illustrations/
|
||||
comic/
|
||||
translate/
|
||||
posts/
|
||||
diagram/
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
|
||||
@@ -2,6 +2,82 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.107.0 - 2026-04-15
|
||||
|
||||
### Features
|
||||
- `baoyu-diagram`: add SVG-to-PNG @2x conversion script — auto-converts generated SVG diagrams to @2x PNG using Sharp; consolidate reference files and add `{baseDir}` path resolution for portable skill loading
|
||||
|
||||
### Fixes
|
||||
- `claude-plugin`: allow inline marketplace manifest (#130)
|
||||
|
||||
## 1.106.0 - 2026-04-14
|
||||
|
||||
### Features
|
||||
- `baoyu-diagram`: add architecture enrichment rules — automatically expand architecture diagrams with multiple client types, per-service tech stacks, database tiers, message buses, and color-coded categories; add full structural layout patterns, architecture-specific pitfalls, network topology templates, and layout math for complex diagrams
|
||||
|
||||
## 1.105.0 - 2026-04-13
|
||||
|
||||
### Features
|
||||
- `baoyu-diagram`: unify to analyze→confirm→generate workflow — remove single/multi mode split; skill now analyzes any input material, recommends diagram types and splitting strategy, confirms once, then generates all diagrams
|
||||
|
||||
## 1.104.0 - 2026-04-13
|
||||
|
||||
### Features
|
||||
- `baoyu-diagram`: add Mermaid sketch step (6d-0) before SVG generation — write a Mermaid code block as structural intent; add Mermaid–SVG consistency check in step 6f
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: verify editor focus before paste and type operations to prevent silent paste failures
|
||||
|
||||
## 1.103.1 - 2026-04-13
|
||||
|
||||
### Fixes
|
||||
- `baoyu-markdown-to-html`: decode HTML entities and strip tags from article summary
|
||||
- `baoyu-post-to-weibo`: decode HTML entities and strip tags from article summary
|
||||
|
||||
## 1.103.0 - 2026-04-12
|
||||
|
||||
### Features
|
||||
- `baoyu-diagram`: add multi-diagram mode — analyze article content and generate multiple diagrams at identified positions; new `--density` option (`minimal`, `balanced`, `per-section`, `rich`) and `--mode` option (`single`, `multi`, `auto`); auto-detects mode from input (file path → multi, short topic → single); inserts diagram image links into article; output structure `diagram/{article-slug}/NN-{type}-{slug}/`
|
||||
|
||||
### Fixes
|
||||
- `baoyu-article-illustrator`: prevent color names and hex codes from appearing as visible text in generated images — add semantic constraint to all palette references and prompt construction rules
|
||||
- `baoyu-cover-image`: prevent color names and hex codes from appearing as visible text in generated images — add constraint to all palette references and prompt template
|
||||
- `baoyu-image-cards`: prevent color names from appearing as visible text in generated images
|
||||
- `baoyu-post-to-wechat`: decode HTML entities and strip HTML tags from article summary before using as WeChat article digest
|
||||
|
||||
## 1.102.0 - 2026-04-12
|
||||
|
||||
### Features
|
||||
- `baoyu-imagine`: add OpenAI-compatible image API dialect — new `--imageApiDialect` flag, `OPENAI_IMAGE_API_DIALECT` env var, and `default_image_api_dialect` config for gateways that expect aspect-ratio `size` plus `metadata.resolution` instead of pixel `size`
|
||||
|
||||
## 1.101.0 - 2026-04-12
|
||||
|
||||
### Features
|
||||
- `baoyu-imagine`: improve Replicate provider compatibility — route models through family-specific input builders and validators (nano-banana, Seedream 4.5, Seedream 5 Lite, Wan 2.7 Image); update default model to `google/nano-banana-2`; fix Seedream 4.5 custom size encoding to use width/height schema; fix aspect-ratio default inheritance for unsupported Replicate models; block multi-output requests before they reach the API (by @justnode)
|
||||
|
||||
## 1.100.0 - 2026-04-12
|
||||
|
||||
### Features
|
||||
- `baoyu-imagine`: add Z.AI GLM-Image provider — supports `glm-image` and `cogview-4-250304` models via the Z.AI sync image API; configure with `ZAI_API_KEY` (or `BIGMODEL_API_KEY` for backward compatibility)
|
||||
|
||||
## 1.99.1 - 2026-04-11
|
||||
|
||||
### Fixes
|
||||
- `baoyu-article-illustrator`: omit `model` field from batch tasks when `--model` is not specified, letting `baoyu-imagine` resolve the default from env/config
|
||||
|
||||
## 1.99.0 - 2026-04-10
|
||||
|
||||
### Features
|
||||
- `baoyu-diagram`: add new skill for generating publication-ready SVG diagrams — flowcharts, structural/architecture diagrams, and illustrative intuition diagrams. Claude writes real SVG code directly following a cohesive design system; output is a single self-contained `.svg` file with embedded styles and auto dark-mode, ready to embed in articles, WeChat posts, slides, and docs
|
||||
|
||||
## 1.98.0 - 2026-04-10
|
||||
|
||||
### Features
|
||||
- `baoyu-xhs-images`: Restore as active skill (remove deprecated warning)
|
||||
- `baoyu-xhs-images`: Add `sketch-notes` style — hand-drawn educational infographic with macaron pastels, wobble lines, and warm cream background
|
||||
- `baoyu-xhs-images`: Add palette system (`macaron`, `warm`, `neon`) as optional `--palette` color override dimension
|
||||
- `baoyu-xhs-images`: Add 3 new presets: `hand-drawn-edu`, `sketch-card`, `sketch-summary`
|
||||
|
||||
## 1.97.1 - 2026-04-09
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -2,6 +2,82 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.107.0 - 2026-04-15
|
||||
|
||||
### 新功能
|
||||
- `baoyu-diagram`:新增 SVG 转 @2x PNG 转换脚本 —— 使用 Sharp 自动将生成的 SVG 图表转为 @2x PNG;精简合并参考文件,新增 `{baseDir}` 路径解析以支持可移植的技能加载
|
||||
|
||||
### 修复
|
||||
- `claude-plugin`:支持内联 marketplace manifest (#130)
|
||||
|
||||
## 1.106.0 - 2026-04-14
|
||||
|
||||
### 新功能
|
||||
- `baoyu-diagram`:新增架构图丰富化规则 —— 自动扩展架构图,补充多客户端类型、各服务技术栈、数据库分层、消息总线和分色分类;新增完整结构布局模式、架构专用陷阱提示、网络拓扑模板和复杂图表布局计算
|
||||
|
||||
## 1.105.0 - 2026-04-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-diagram`:统一为分析→确认→生成工作流 —— 移除单图/多图模式区分;技能现在分析任意输入素材,推荐图表类型和拆分策略,一次确认后批量生成所有图表
|
||||
|
||||
## 1.104.0 - 2026-04-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-diagram`:新增 Mermaid 草图步骤(6d-0),在生成 SVG 前先写 Mermaid 代码块作为结构意图;在步骤 6f 新增 Mermaid–SVG 一致性检查
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:在粘贴和输入操作前校验编辑器焦点,避免粘贴静默失败
|
||||
|
||||
## 1.103.1 - 2026-04-13
|
||||
|
||||
### 修复
|
||||
- `baoyu-markdown-to-html`:修复文章摘要中 HTML 实体未解码及 HTML 标签未剥离的问题
|
||||
- `baoyu-post-to-weibo`:修复文章摘要中 HTML 实体未解码及 HTML 标签未剥离的问题
|
||||
|
||||
## 1.103.0 - 2026-04-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-diagram`:新增多图模式 —— 分析文章内容,在识别出的位置批量生成图表;新增 `--density` 参数(`minimal`、`balanced`、`per-section`、`rich`)和 `--mode` 参数(`single`、`multi`、`auto`);根据输入自动判断模式(文件路径→多图,短主题→单图);自动在文章中插入图表链接;输出目录结构 `diagram/{article-slug}/NN-{type}-{slug}/`
|
||||
|
||||
### 修复
|
||||
- `baoyu-article-illustrator`:修复生成图像中出现颜色名称和色值文字的问题 —— 在所有调色板参考文件和提示构建规则中添加语义约束
|
||||
- `baoyu-cover-image`:修复生成图像中出现颜色名称和色值文字的问题 —— 在所有调色板参考文件和提示模板中添加约束
|
||||
- `baoyu-image-cards`:修复生成图像中出现颜色名称文字的问题
|
||||
- `baoyu-post-to-wechat`:修复文章摘要中 HTML 实体未解码及 HTML 标签未剥离的问题,避免微信文章摘要显示乱码
|
||||
|
||||
## 1.102.0 - 2026-04-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-imagine`:新增 OpenAI 兼容图像 API 方言支持 —— 新增 `--imageApiDialect` 参数、`OPENAI_IMAGE_API_DIALECT` 环境变量及 `default_image_api_dialect` 配置项,用于对接期望宽高比格式 `size` 加 `metadata.resolution` 的兼容网关
|
||||
|
||||
## 1.101.0 - 2026-04-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-imagine`:改进 Replicate 服务商兼容性 —— 针对不同模型系列(nano-banana、Seedream 4.5、Seedream 5 Lite、Wan 2.7 Image)实现专属输入构建器和验证器;将默认模型更新为 `google/nano-banana-2`;修复 Seedream 4.5 自定义尺寸编码(改用 width/height schema);修复不支持的 Replicate 模型的宽高比默认值继承问题;在请求到达 API 前拦截多图请求 (by @justnode)
|
||||
|
||||
## 1.100.0 - 2026-04-12
|
||||
|
||||
### 新功能
|
||||
- `baoyu-imagine`:新增 Z.AI GLM-Image 服务商支持,支持 `glm-image` 和 `cogview-4-250304` 模型,通过 Z.AI 同步图像 API 调用;配置 `ZAI_API_KEY`(或 `BIGMODEL_API_KEY` 向后兼容)
|
||||
|
||||
## 1.99.1 - 2026-04-11
|
||||
|
||||
### 修复
|
||||
- `baoyu-article-illustrator`:未指定 `--model` 时,批处理任务中不再写入 `model` 字段,改由 `baoyu-imagine` 从环境变量或配置中解析默认值
|
||||
|
||||
## 1.99.0 - 2026-04-10
|
||||
|
||||
### 新功能
|
||||
- `baoyu-diagram`:新增技能,用于生成可直接发布的 SVG 图表 —— 包括流程图、架构/结构图、示意图(直觉图解)。Claude 直接输出符合统一设计规范的真实 SVG 代码,产物是单个自包含的 `.svg` 文件,内嵌样式并自动支持深色模式,可直接嵌入文章、微信公众号、幻灯片和文档中
|
||||
|
||||
## 1.98.0 - 2026-04-10
|
||||
|
||||
### 新功能
|
||||
- `baoyu-xhs-images`:恢复为正式技能(移除废弃警告)
|
||||
- `baoyu-xhs-images`:新增 `sketch-notes` 风格 —— 手绘教育信息图,马卡龙配色,波动线条,暖奶油背景
|
||||
- `baoyu-xhs-images`:新增配色系统(`macaron`、`warm`、`neon`),支持 `--palette` 参数覆盖风格默认颜色
|
||||
- `baoyu-xhs-images`:新增 3 个预设:`hand-drawn-edu`、`sketch-card`、`sketch-summary`
|
||||
|
||||
## 1.97.1 - 2026-04-09
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.97.0**.
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.107.0**.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -97,32 +97,32 @@ Skills are organized into three categories:
|
||||
|
||||
Content generation and publishing skills.
|
||||
|
||||
#### baoyu-image-cards
|
||||
#### baoyu-xhs-images
|
||||
|
||||
Image card series generator. Breaks down content into 1-10 cartoon-style image cards with **Style × Layout** system and optional palette override.
|
||||
Xiaohongshu image card series generator. Breaks down content into 1-10 cartoon-style image cards with **Style × Layout** system and optional palette override.
|
||||
|
||||
```bash
|
||||
# Auto-select style and layout
|
||||
/baoyu-image-cards posts/ai-future/article.md
|
||||
/baoyu-xhs-images posts/ai-future/article.md
|
||||
|
||||
# Specify style
|
||||
/baoyu-image-cards posts/ai-future/article.md --style notion
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion
|
||||
|
||||
# Specify layout
|
||||
/baoyu-image-cards posts/ai-future/article.md --layout dense
|
||||
/baoyu-xhs-images posts/ai-future/article.md --layout dense
|
||||
|
||||
# Combine style and layout
|
||||
/baoyu-image-cards posts/ai-future/article.md --style notion --layout list
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion --layout list
|
||||
|
||||
# Override palette
|
||||
/baoyu-image-cards posts/ai-future/article.md --style notion --palette macaron
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion --palette macaron
|
||||
|
||||
# Direct content input
|
||||
/baoyu-image-cards 今日星座运势
|
||||
/baoyu-xhs-images 今日星座运势
|
||||
|
||||
# Non-interactive (skip all confirmations, for scheduled tasks)
|
||||
/baoyu-image-cards posts/ai-future/article.md --yes
|
||||
/baoyu-image-cards posts/ai-future/article.md --yes --preset knowledge-card
|
||||
/baoyu-xhs-images posts/ai-future/article.md --yes
|
||||
/baoyu-xhs-images posts/ai-future/article.md --yes --preset knowledge-card
|
||||
```
|
||||
|
||||
**Styles** (visual aesthetics): `cute` (default), `fresh`, `warm`, `bold`, `minimal`, `retro`, `pop`, `notion`, `chalkboard`, `study-notes`, `screen-print`, `sketch-notes`
|
||||
@@ -272,6 +272,43 @@ Generate professional infographics with 21 layout types and 21 visual styles. An
|
||||
|  |  | |
|
||||
| knolling | lego-brick | |
|
||||
|
||||
#### baoyu-diagram
|
||||
|
||||
Generate publication-ready SVG diagrams from source material — flowcharts, sequence/protocol diagrams, structural/architecture diagrams, and illustrative intuition diagrams. Analyzes input material to recommend diagram type(s) and splitting strategy, confirms the plan once, then generates all diagrams. Claude writes real SVG code directly following a cohesive design system. Output is self-contained `.svg` files with embedded styles and auto dark-mode.
|
||||
|
||||
```bash
|
||||
# Topic string — skill analyzes and proposes a plan
|
||||
/baoyu-diagram "how JWT authentication works"
|
||||
/baoyu-diagram "Kubernetes architecture" --type structural
|
||||
/baoyu-diagram "OAuth 2.0 flow" --type sequence
|
||||
|
||||
# File path — skill reads, analyzes, and proposes a plan
|
||||
/baoyu-diagram path/to/article.md
|
||||
|
||||
# Language and output path
|
||||
/baoyu-diagram "微服务架构" --lang zh
|
||||
/baoyu-diagram "build pipeline" --out docs/build-pipeline.svg
|
||||
```
|
||||
|
||||
**Options**:
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--type <name>` | `flowchart`, `sequence`, `structural`, `illustrative`, `class`, `auto` (default). Skips type recommendation. |
|
||||
| `--lang <code>` | Output language (en, zh, ja, ...) |
|
||||
| `--out <path>` | Output file path. Generates exactly one diagram focused on the most important aspect. |
|
||||
|
||||
**Diagram types**:
|
||||
|
||||
| Type | Reader need | Verbs that trigger it |
|
||||
|------|-------------|------------------------|
|
||||
| `flowchart` | Walk me through the steps in order | walk through, steps, process, lifecycle, workflow, state machine |
|
||||
| `sequence` | Who talks to whom, in what order | protocol, handshake, auth flow, OAuth, TCP, request/response |
|
||||
| `structural` | Show me what's inside what, how it's organised | architecture, components, topology, layout, what's inside |
|
||||
| `illustrative` | Give me the intuition — draw the mechanism | how does X work, explain X, intuition for, why does X do Y |
|
||||
| `class` | What are the types and how are they related | class diagram, UML, inheritance, interface, schema |
|
||||
|
||||
Not an image-generation skill — no LLM image model is called. Claude writes the SVG by hand with hand-computed layout math, so every diagram honors the design system. Embedded `<style>` block with `@media (prefers-color-scheme: dark)` means the same file renders correctly in both light and dark mode anywhere it's embedded.
|
||||
|
||||
#### baoyu-cover-image
|
||||
|
||||
Generate cover images for articles with 5 dimensions: Type × Palette × Rendering × Text × Mood. Combines 11 color palettes with 7 rendering styles for 77 unique combinations.
|
||||
@@ -707,15 +744,24 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
# DashScope with custom size
|
||||
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||
|
||||
# Z.AI GLM-Image
|
||||
/baoyu-imagine --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
|
||||
|
||||
# MiniMax
|
||||
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||
|
||||
# MiniMax with subject reference
|
||||
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||
|
||||
# Replicate
|
||||
# Replicate (default: google/nano-banana-2)
|
||||
/baoyu-imagine --prompt "A cat" --image cat.png --provider replicate
|
||||
|
||||
# Replicate Seedream 4.5
|
||||
/baoyu-imagine --prompt "A studio portrait" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
|
||||
|
||||
# Replicate Wan 2.7 Image Pro
|
||||
/baoyu-imagine --prompt "A concept frame" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
|
||||
|
||||
# Jimeng (即梦)
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
@@ -737,14 +783,15 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
| `--image` | Output image path (required) |
|
||||
| `--batchfile` | JSON batch file for multi-image generation |
|
||||
| `--jobs` | Worker count for batch mode |
|
||||
| `--provider` | `google`, `openai`, `azure`, `openrouter`, `dashscope`, `minimax`, `jimeng`, `seedream`, or `replicate` |
|
||||
| `--model`, `-m` | Model ID or deployment name. Azure uses deployment name; OpenRouter uses full model IDs; MiniMax uses `image-01` / `image-01-live` |
|
||||
| `--provider` | `google`, `openai`, `azure`, `openrouter`, `dashscope`, `zai`, `minimax`, `jimeng`, `seedream`, or `replicate` |
|
||||
| `--model`, `-m` | Model ID or deployment name. Azure uses deployment name; OpenRouter uses full model IDs; Z.AI uses `glm-image`; MiniMax uses `image-01` / `image-01-live` |
|
||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size` | Size (e.g., `1024x1024`) |
|
||||
| `--quality` | `normal` or `2k` (default: `2k`) |
|
||||
| `--imageSize` | `1K`, `2K`, or `4K` for Google/OpenRouter |
|
||||
| `--ref` | Reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | Number of images per request |
|
||||
| `--imageApiDialect` | `openai-native` or `ratio-metadata` for OpenAI-compatible gateways |
|
||||
| `--ref` | Reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate supported families, MiniMax, or Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | Number of images per request (`replicate` currently requires `--n 1`) |
|
||||
| `--json` | JSON output |
|
||||
|
||||
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
|
||||
@@ -756,6 +803,8 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
| `GOOGLE_API_KEY` | Google API key | - |
|
||||
| `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
||||
| `ZAI_API_KEY` | Z.AI API key | - |
|
||||
| `BIGMODEL_API_KEY` | Backward-compatible alias for Z.AI API key | - |
|
||||
| `MINIMAX_API_KEY` | MiniMax API key | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - |
|
||||
@@ -767,11 +816,14 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
| `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 | `qwen-image-2.0-pro` |
|
||||
| `ZAI_IMAGE_MODEL` | Z.AI model | `glm-image` |
|
||||
| `BIGMODEL_IMAGE_MODEL` | Backward-compatible alias for Z.AI model | `glm-image` |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax model | `image-01` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-2` |
|
||||
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
||||
| `OPENAI_IMAGE_API_DIALECT` | OpenAI-compatible image API dialect (`openai-native` or `ratio-metadata`) | `openai-native` |
|
||||
| `OPENAI_IMAGE_USE_CHAT` | Use `/chat/completions` for OpenAI image generation | `false` |
|
||||
| `AZURE_OPENAI_BASE_URL` | Azure resource or deployment endpoint | - |
|
||||
| `AZURE_API_VERSION` | Azure image API version | `2025-04-01-preview` |
|
||||
@@ -780,6 +832,8 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution | - |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||
| `ZAI_BASE_URL` | Custom Z.AI endpoint | `https://api.z.ai/api/paas/v4` |
|
||||
| `BIGMODEL_BASE_URL` | Backward-compatible alias for Z.AI endpoint | - |
|
||||
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint | `https://api.minimax.io` |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
|
||||
@@ -792,16 +846,20 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
||||
**Provider Notes**:
|
||||
- Azure OpenAI: `--model` means Azure deployment name, not the underlying model family.
|
||||
- DashScope: `qwen-image-2.0-pro` is the recommended default for custom `--size`, `21:9`, and strong Chinese/English text rendering.
|
||||
- Z.AI: `glm-image` is recommended for posters, diagrams, and text-heavy Chinese/English images. Reference images are not supported.
|
||||
- MiniMax: `image-01` supports documented custom `width` / `height`; `image-01-live` is lower latency and works best with `--ar`.
|
||||
- MiniMax reference images are sent as `subject_reference`; the current API is specialized toward character / portrait consistency.
|
||||
- Jimeng does not support reference images.
|
||||
- Seedream reference images are supported by Seedream 5.0 / 4.5 / 4.0, not Seedream 3.0.
|
||||
- Replicate defaults to `google/nano-banana-2`. `baoyu-imagine` only enables Replicate advanced options for `google/nano-banana*`, `bytedance/seedream-4.5`, `bytedance/seedream-5-lite`, `wan-video/wan-2.7-image`, and `wan-video/wan-2.7-image-pro`.
|
||||
- Replicate currently saves exactly one output image per request. `--n > 1` is blocked locally instead of silently dropping extra results.
|
||||
- Replicate model behavior is family-specific: nano-banana uses `--quality` / `--ar`, Seedream uses validated `--size` / `--ar`, and Wan uses validated `--size` (with `--ar` converted locally to a concrete size).
|
||||
|
||||
**Provider Auto-Selection**:
|
||||
1. If `--provider` is specified → use it
|
||||
2. If `--ref` is provided and no provider is specified → try Google, then OpenAI, Azure, OpenRouter, Replicate, Seedream, and finally MiniMax
|
||||
3. If only one API key is available → use that provider
|
||||
4. If multiple providers are available → default to Google
|
||||
4. If multiple providers are available → default to Google, then OpenAI, Azure, OpenRouter, DashScope, Z.AI, MiniMax, Replicate, Jimeng, Seedream
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
@@ -1101,6 +1159,11 @@ DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
|
||||
# Z.AI
|
||||
ZAI_API_KEY=xxx
|
||||
ZAI_IMAGE_MODEL=glm-image
|
||||
# ZAI_BASE_URL=https://api.z.ai/api/paas/v4
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_API_KEY=xxx
|
||||
MINIMAX_IMAGE_MODEL=image-01
|
||||
@@ -1108,7 +1171,7 @@ MINIMAX_IMAGE_MODEL=image-01
|
||||
|
||||
# Replicate
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-2
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# Jimeng (即梦)
|
||||
@@ -1201,6 +1264,7 @@ This project was inspired by and builds upon the following open source projects:
|
||||
- [doocs/md](https://github.com/doocs/md) by [@doocs](https://github.com/doocs) — Core implementation logic for Markdown to HTML conversion
|
||||
- [High-density Infographic Prompt](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg) by AJ@WaytoAGI — Inspiration for the infographic skill
|
||||
- [qiaomu-mondo-poster-design](https://github.com/joeseesun/qiaomu-mondo-poster-design) by [@joeseesun](https://github.com/joeseesun)(乔木) — Inspiration for the Mondo style
|
||||
- [architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) by [@Cocoon-AI](https://github.com/Cocoon-AI) — Inspiration for the diagram skill's design system
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+82
-18
@@ -97,32 +97,32 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
内容生成和发布技能。
|
||||
|
||||
#### baoyu-image-cards
|
||||
#### baoyu-xhs-images
|
||||
|
||||
图片卡片系列生成器。将内容拆解为 1-10 张卡通风格图片卡片,支持 **风格 × 布局** 系统和可选配色覆盖。
|
||||
小红书图片卡片系列生成器。将内容拆解为 1-10 张卡通风格图片卡片,支持 **风格 × 布局** 系统和可选配色覆盖。
|
||||
|
||||
```bash
|
||||
# 自动选择风格和布局
|
||||
/baoyu-image-cards posts/ai-future/article.md
|
||||
/baoyu-xhs-images posts/ai-future/article.md
|
||||
|
||||
# 指定风格
|
||||
/baoyu-image-cards posts/ai-future/article.md --style notion
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion
|
||||
|
||||
# 指定布局
|
||||
/baoyu-image-cards posts/ai-future/article.md --layout dense
|
||||
/baoyu-xhs-images posts/ai-future/article.md --layout dense
|
||||
|
||||
# 组合风格和布局
|
||||
/baoyu-image-cards posts/ai-future/article.md --style notion --layout list
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion --layout list
|
||||
|
||||
# 覆盖配色
|
||||
/baoyu-image-cards posts/ai-future/article.md --style notion --palette macaron
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion --palette macaron
|
||||
|
||||
# 直接输入内容
|
||||
/baoyu-image-cards 今日星座运势
|
||||
/baoyu-xhs-images 今日星座运势
|
||||
|
||||
# 非交互模式(跳过所有确认,适用于定时任务)
|
||||
/baoyu-image-cards posts/ai-future/article.md --yes
|
||||
/baoyu-image-cards posts/ai-future/article.md --yes --preset knowledge-card
|
||||
/baoyu-xhs-images posts/ai-future/article.md --yes
|
||||
/baoyu-xhs-images posts/ai-future/article.md --yes --preset knowledge-card
|
||||
```
|
||||
|
||||
**风格**(视觉美学):`cute`(默认)、`fresh`、`warm`、`bold`、`minimal`、`retro`、`pop`、`notion`、`chalkboard`、`study-notes`、`screen-print`、`sketch-notes`
|
||||
@@ -272,6 +272,43 @@ clawhub install baoyu-markdown-to-html
|
||||
|  |  | |
|
||||
| knolling | lego-brick | |
|
||||
|
||||
#### baoyu-diagram
|
||||
|
||||
从源素材生成可直接发布的 SVG 图表 —— 包括流程图、时序/协议图、架构/结构图、示意图(直觉图解)。分析输入素材,推荐图表类型和拆分策略,一次确认后批量生成。Claude 直接输出符合统一设计规范的真实 SVG 代码,产物是自包含的 `.svg` 文件,内嵌样式并自动支持深色模式。
|
||||
|
||||
```bash
|
||||
# 主题描述 —— 技能分析并提出方案
|
||||
/baoyu-diagram "JWT 认证流程是怎么工作的"
|
||||
/baoyu-diagram "Kubernetes 架构" --type structural
|
||||
/baoyu-diagram "OAuth 2.0 流程" --type sequence
|
||||
|
||||
# 文件路径 —— 技能读取、分析并提出方案
|
||||
/baoyu-diagram path/to/article.md
|
||||
|
||||
# 语言和输出路径
|
||||
/baoyu-diagram "微服务架构" --lang zh
|
||||
/baoyu-diagram "build pipeline" --out docs/build-pipeline.svg
|
||||
```
|
||||
|
||||
**参数**:
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--type <name>` | `flowchart`、`sequence`、`structural`、`illustrative`、`class`、`auto`(默认)。跳过类型推荐直接生成。 |
|
||||
| `--lang <code>` | 输出语言(en、zh、ja 等) |
|
||||
| `--out <path>` | 输出文件路径。生成聚焦于最重要内容的单张图表。 |
|
||||
|
||||
**五种图表类型**:
|
||||
|
||||
| 类型 | 适用场景 | 触发动词 |
|
||||
|------|----------|----------|
|
||||
| `flowchart` | 按顺序走一遍流程 | 流程、步骤、工作流、生命周期、状态机 |
|
||||
| `sequence` | 谁和谁通信、按什么顺序 | 协议、握手、认证流程、OAuth、TCP、请求/响应 |
|
||||
| `structural` | 展示什么包含什么、如何组织 | 架构、组件、拓扑、布局、什么在什么里面 |
|
||||
| `illustrative` | 建立直觉 —— 画出机制本身 | 怎么工作、原理、为什么、直观解释 |
|
||||
| `class` | 类型是什么、它们如何关联 | 类图、UML、继承、接口、数据模型 |
|
||||
|
||||
本技能不调用任何图像生成模型 —— Claude 通过手算坐标直接写 SVG 代码,确保每个图表都遵守设计规范。内嵌的 `<style>` 块包含 `@media (prefers-color-scheme: dark)`,同一个文件在浅色和深色模式下均正确渲染,可嵌入到任意支持 SVG 的宿主环境中。
|
||||
|
||||
#### baoyu-cover-image
|
||||
|
||||
为文章生成封面图,支持五维定制系统:类型 × 配色 × 渲染 × 文字 × 氛围。11 种配色方案与 7 种渲染风格组合,提供 77 种独特效果。
|
||||
@@ -707,15 +744,24 @@ AI 驱动的生成后端。
|
||||
# DashScope 自定义尺寸
|
||||
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||
|
||||
# Z.AI GLM-Image
|
||||
/baoyu-imagine --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
|
||||
|
||||
# MiniMax
|
||||
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||
|
||||
# MiniMax + 角色参考图
|
||||
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||
|
||||
# Replicate
|
||||
# Replicate(默认:google/nano-banana-2)
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider replicate
|
||||
|
||||
# Replicate Seedream 4.5
|
||||
/baoyu-imagine --prompt "一张影棚人像" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
|
||||
|
||||
# Replicate Wan 2.7 Image Pro
|
||||
/baoyu-imagine --prompt "一张概念分镜" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
|
||||
|
||||
# 即梦(Jimeng)
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
@@ -737,14 +783,15 @@ AI 驱动的生成后端。
|
||||
| `--image` | 输出图片路径(必需) |
|
||||
| `--batchfile` | 多图批量生成的 JSON 文件 |
|
||||
| `--jobs` | 批量模式的并发 worker 数 |
|
||||
| `--provider` | `google`、`openai`、`azure`、`openrouter`、`dashscope`、`minimax`、`jimeng`、`seedream` 或 `replicate` |
|
||||
| `--model`, `-m` | 模型 ID 或部署名。Azure 使用部署名;OpenRouter 使用完整模型 ID;MiniMax 使用 `image-01` / `image-01-live` |
|
||||
| `--provider` | `google`、`openai`、`azure`、`openrouter`、`dashscope`、`zai`、`minimax`、`jimeng`、`seedream` 或 `replicate` |
|
||||
| `--model`, `-m` | 模型 ID 或部署名。Azure 使用部署名;OpenRouter 使用完整模型 ID;Z.AI 使用 `glm-image`;MiniMax 使用 `image-01` / `image-01-live` |
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`) |
|
||||
| `--quality` | `normal` 或 `2k`(默认:`2k`) |
|
||||
| `--imageSize` | Google/OpenRouter 使用的 `1K`、`2K`、`4K` |
|
||||
| `--ref` | 参考图片(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | 单次请求生成图片数量 |
|
||||
| `--imageApiDialect` | OpenAI 兼容网关的图像 API 方言(`openai-native` 或 `ratio-metadata`) |
|
||||
| `--ref` | 参考图片(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate 支持的模型家族、MiniMax 或 Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | 单次请求生成图片数量(`replicate` 当前只支持 `--n 1`) |
|
||||
| `--json` | 输出 JSON 结果 |
|
||||
|
||||
**环境变量**(配置方法见[环境配置](#环境配置)):
|
||||
@@ -756,6 +803,8 @@ AI 驱动的生成后端。
|
||||
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
||||
| `GEMINI_API_KEY` | `GOOGLE_API_KEY` 的别名 | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
||||
| `ZAI_API_KEY` | Z.AI API 密钥 | - |
|
||||
| `BIGMODEL_API_KEY` | Z.AI API 密钥向后兼容别名 | - |
|
||||
| `MINIMAX_API_KEY` | MiniMax API 密钥 | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API Token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - |
|
||||
@@ -767,11 +816,14 @@ AI 驱动的生成后端。
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
|
||||
| `ZAI_IMAGE_MODEL` | Z.AI 模型 | `glm-image` |
|
||||
| `BIGMODEL_IMAGE_MODEL` | Z.AI 模型向后兼容别名 | `glm-image` |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax 模型 | `image-01` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-2` |
|
||||
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||
| `OPENAI_IMAGE_API_DIALECT` | OpenAI 兼容图像 API 方言(`openai-native` 或 `ratio-metadata`) | `openai-native` |
|
||||
| `OPENAI_IMAGE_USE_CHAT` | OpenAI 改走 `/chat/completions` | `false` |
|
||||
| `AZURE_OPENAI_BASE_URL` | Azure 资源或部署端点 | - |
|
||||
| `AZURE_API_VERSION` | Azure 图像 API 版本 | `2025-04-01-preview` |
|
||||
@@ -780,6 +832,8 @@ AI 驱动的生成后端。
|
||||
| `OPENROUTER_TITLE` | OpenRouter 归因用应用名 | - |
|
||||
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||
| `ZAI_BASE_URL` | 自定义 Z.AI 端点 | `https://api.z.ai/api/paas/v4` |
|
||||
| `BIGMODEL_BASE_URL` | Z.AI 端点向后兼容别名 | - |
|
||||
| `MINIMAX_BASE_URL` | 自定义 MiniMax 端点 | `https://api.minimax.io` |
|
||||
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
|
||||
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
|
||||
@@ -792,16 +846,20 @@ AI 驱动的生成后端。
|
||||
**Provider 说明**:
|
||||
- Azure OpenAI:`--model` 表示 Azure deployment name,不是底层模型家族名。
|
||||
- DashScope:`qwen-image-2.0-pro` 是自定义 `--size`、`21:9` 和中英文排版的推荐默认模型。
|
||||
- Z.AI:`glm-image` 适合海报、图表和中英文排版密集的图片生成,暂不支持参考图。
|
||||
- MiniMax:`image-01` 支持官方文档里的自定义 `width` / `height`;`image-01-live` 更偏低延迟,适合配合 `--ar` 使用。
|
||||
- MiniMax 参考图会走 `subject_reference`,当前能力更偏角色 / 人像一致性。
|
||||
- 即梦不支持参考图。
|
||||
- 豆包参考图能力仅适用于 Seedream 5.0 / 4.5 / 4.0,不适用于 Seedream 3.0。
|
||||
- Replicate 默认模型改为 `google/nano-banana-2`。`baoyu-imagine` 目前只对 `google/nano-banana*`、`bytedance/seedream-4.5`、`bytedance/seedream-5-lite`、`wan-video/wan-2.7-image` 和 `wan-video/wan-2.7-image-pro` 开启本地能力识别与校验。
|
||||
- Replicate 当前只保存单张输出图,`--n > 1` 会在本地直接报错,避免多图结果被静默丢弃。
|
||||
- Replicate 的参数能力按模型家族区分:nano-banana 走 `--quality` / `--ar`,Seedream 走校验后的 `--size` / `--ar`,Wan 走校验后的 `--size`(`--ar` 会先在本地换算成具体尺寸)。
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream,最后是 MiniMax
|
||||
3. 如果只有一个 API 密钥 → 使用对应服务商
|
||||
4. 如果多个可用 → 默认使用 Google
|
||||
4. 如果多个可用 → 默认使用 Google,然后依次为 OpenAI、Azure、OpenRouter、DashScope、Z.AI、MiniMax、Replicate、即梦、豆包
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
@@ -1101,6 +1159,11 @@ DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
|
||||
# Z.AI
|
||||
ZAI_API_KEY=xxx
|
||||
ZAI_IMAGE_MODEL=glm-image
|
||||
# ZAI_BASE_URL=https://api.z.ai/api/paas/v4
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_API_KEY=xxx
|
||||
MINIMAX_IMAGE_MODEL=image-01
|
||||
@@ -1108,7 +1171,7 @@ MINIMAX_IMAGE_MODEL=image-01
|
||||
|
||||
# Replicate
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-2
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# 即梦(Jimeng)
|
||||
@@ -1201,6 +1264,7 @@ HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 /baoyu-danger
|
||||
- [doocs/md](https://github.com/doocs/md) by [@doocs](https://github.com/doocs) — Markdown 转 HTML 的核心实现逻辑
|
||||
- [高密度信息图 Prompt](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg) by AJ@WaytoAGI — 信息图技能的灵感来源
|
||||
- [qiaomu-mondo-poster-design](https://github.com/joeseesun/qiaomu-mondo-poster-design) by [@joeseesun](https://github.com/joeseesun)(乔木) — Mondo 风格的灵感来源
|
||||
- [architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) by [@Cocoon-AI](https://github.com/Cocoon-AI) — 图表技能设计体系的灵感来源
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
"pptxgenjs": "^4.0.1",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
cleanSummaryText,
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
parseFrontmatter,
|
||||
@@ -91,3 +92,19 @@ This is **the first paragraph** with [a link](https://example.com) and \`inline
|
||||
"This is the first paragraph with a link and inline code that should...",
|
||||
);
|
||||
});
|
||||
|
||||
test("summary extraction normalizes raw HTML paragraphs to plain text", () => {
|
||||
const summary = extractSummaryFromBody(
|
||||
`
|
||||
# Heading
|
||||
<p style="font-size: 16px; color: #666; margin-bottom: 20px;">2026年初,一只“龙虾”搅动了整个科技圈。腾讯楼下排起近千人长队,只为让工程师领取一份福利。</p>
|
||||
`,
|
||||
120,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary,
|
||||
"2026年初,一只“龙虾”搅动了整个科技圈。腾讯楼下排起近千人长队,只为让工程师领取一份福利。",
|
||||
);
|
||||
assert.equal(cleanSummaryText("<strong>Good text!'</strong>"), "Good text!'");
|
||||
});
|
||||
|
||||
@@ -46,6 +46,45 @@ export function stripWrappingQuotes(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
function decodeHtmlCodePoint(codePoint: number, fallback: string): string {
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body: string) => {
|
||||
const normalized = body.toLowerCase();
|
||||
if (normalized.startsWith("#x")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(2), 16), entity);
|
||||
}
|
||||
if (normalized.startsWith("#")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(1), 10), entity);
|
||||
}
|
||||
return HTML_ENTITIES[normalized] ?? entity;
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanSummaryText(value: string): string {
|
||||
return decodeHtmlEntities(stripWrappingQuotes(value))
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?[a-z][a-z0-9:-]*(?:\s+[^>]*)?>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
@@ -94,10 +133,11 @@ export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
const summaryText = cleanSummaryText(cleanText);
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
if (summaryText.length > 20) {
|
||||
if (summaryText.length <= maxLen) return summaryText;
|
||||
return `${summaryText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,22 @@ test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
|
||||
assert.match(html, /<article>Hello<\/article>/);
|
||||
});
|
||||
|
||||
test("buildHtmlDocument escapes head metadata attributes", () => {
|
||||
const html = buildHtmlDocument(
|
||||
{
|
||||
title: `Doc <draft>`,
|
||||
author: `Bao"yu`,
|
||||
description: `<p style="color: red">Summary & notes</p>`,
|
||||
},
|
||||
"",
|
||||
"",
|
||||
);
|
||||
|
||||
assert.match(html, /<title>Doc <draft><\/title>/);
|
||||
assert.match(html, /meta name="author" content="Bao"yu"/);
|
||||
assert.match(html, /meta name="description" content="<p style="color: red">Summary & notes<\/p>"/);
|
||||
});
|
||||
|
||||
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
|
||||
const rawCss = `
|
||||
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
|
||||
|
||||
@@ -45,19 +45,24 @@ export function loadCodeThemeCss(themeName: string): string {
|
||||
}
|
||||
|
||||
export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const escapeHtmlAttribute = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${meta.title}</title>`,
|
||||
` <title>${escapeHtmlAttribute(meta.title)}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
lines.push(` <meta name="author" content="${escapeHtmlAttribute(meta.author)}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
lines.push(` <meta name="description" content="${escapeHtmlAttribute(meta.description)}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
|
||||
@@ -24,6 +24,10 @@ Soft macaron pastel color blocks on warm cream
|
||||
|
||||
Coral Red (#E8655A) for key data, warnings, and emphasis highlights. Use sparingly — one or two elements per illustration.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Soft pastel macaron color palette. Use block colors as rounded card backgrounds for distinct information sections. Accent coral red sparingly for emphasis on key terms only. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, knowledge sharing, concept explainers, tutorials, tech summaries, onboarding materials
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# mono-ink
|
||||
|
||||
Black ink on pure white with sparse semantic accent colors
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Pure White (#FFFFFF)
|
||||
- Texture: Clean, no grain, no tint
|
||||
|
||||
## Colors
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Pure White | #FFFFFF | Canvas |
|
||||
| Primary | Near Black | #1A1A1A | All lines, text, figures, arrows |
|
||||
| Accent (risk/emphasis) | Coral Red | #E8655A | Risk, problem, gap, key emphasis |
|
||||
| Accent (positive) | Muted Teal | #5FA8A8 | Positive, solution, "after" state |
|
||||
| Accent (neutral tag) | Dusty Lavender | #9B8AB5 | Neutral tags, category labels |
|
||||
| Soft Fill | Pale Gray | #F0F0F0 | Subtle zone backgrounds (optional) |
|
||||
|
||||
## Accent
|
||||
|
||||
Use black ink for all structural elements — lines, text, figures. Accent colors appear only for semantic highlighting: coral red for risks/gaps/problems, muted teal for positive/solution/after-states, dusty lavender for neutral category tags. Total colored pixels must remain under 10% of canvas. Pale gray may back a subtle zone but must never dominate.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Black ink on white canvas. Accent colors for semantic highlighting only — total colored pixels under 10% of canvas. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Compatible With
|
||||
|
||||
- `ink-notes` (primary, default pairing)
|
||||
- `minimal` (strict monochrome variation, drops the style's built-in accent)
|
||||
- `sketch` (pencil + ink hybrid look)
|
||||
|
||||
## Not Recommended With
|
||||
|
||||
- `sketch-notes` — its "no pure white backgrounds" rule conflicts
|
||||
- `warm`, `elegant`, `watercolor`, `fantasy-animation` — color-heavy by design, mono-ink strips their identity
|
||||
|
||||
## Best For
|
||||
|
||||
Professional visual notes, Before/After essays, tech manifestos, framework analogies, whiteboard-presentation explainers
|
||||
@@ -24,6 +24,10 @@ Vibrant neon colors on dark backgrounds
|
||||
|
||||
Hot Pink (#FF1493) for primary emphasis. High contrast neon-on-dark creates immediate visual impact.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Vibrant neon-on-dark palette. High contrast, immediate visual impact. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Gaming, retro tech, 80s/90s nostalgic content, bold editorial, trend and pop culture
|
||||
|
||||
@@ -23,6 +23,10 @@ Warm earth tones on soft peach, no cool colors
|
||||
|
||||
Warm Orange (#ED8936) for primary emphasis. Warm-only palette — no cool colors (no green, blue, purple). Modern-retro feel.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Warm earth tone palette. Warm-only — no cool colors (no green, blue, purple). Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Product showcases, team introductions, feature grids, brand content, personal growth, lifestyle
|
||||
|
||||
@@ -67,6 +67,17 @@ STYLE (from reference):
|
||||
|
||||
---
|
||||
|
||||
## Color Specification Rules
|
||||
|
||||
Colors in prompts use hex codes for **rendering guidance only** — they tell the model which colors to use, NOT what text to display.
|
||||
|
||||
**⚠️ CRITICAL**: Image generation models sometimes render color names and hex values as visible text labels in the image (e.g., painting "Macaron Blue #A8D8EA" as a label). This must be prevented.
|
||||
|
||||
**Add to ALL prompts that contain a COLORS section**:
|
||||
> Color values (#hex) and color names are rendering guidance only — do NOT display color names, hex codes, or palette labels as visible text in the image.
|
||||
|
||||
---
|
||||
|
||||
## Character Rendering
|
||||
|
||||
When depicting people:
|
||||
@@ -193,6 +204,22 @@ ELEMENTS: Rounded cards with dashed/solid borders, wavy hand-drawn arrows with l
|
||||
STYLE: Color fills don't completely fill outlines, hand-drawn lettering, generous white space
|
||||
```
|
||||
|
||||
**Flowchart + ink-notes + mono-ink palette**:
|
||||
```
|
||||
Professional hand-drawn visual-note flowchart on pure white. Black ink line work
|
||||
with slight wobble, à la Mike Rohde sketchnoting.
|
||||
PALETTE: mono-ink — black ink dominant, sparse semantic accents
|
||||
COLORS: Pure White background (#FFFFFF), Near Black (#1A1A1A) for all lines,
|
||||
text, and figures; Coral Red (#E8655A) only for risk/emphasis,
|
||||
Muted Teal (#5FA8A8) only for positive/solution states
|
||||
ELEMENTS: Left-to-right stage boxes with rounded-rect frames, wavy hand-drawn
|
||||
arrows between stages, simple stick-figure characters with role
|
||||
labels above (e.g., "ML Engineer", "Team Lead"), dashed-border box
|
||||
for future/empty stage, small doodle icons per stage
|
||||
STYLE: Hand-lettered titles (bold, oversized), handwritten stage labels and
|
||||
annotations, generous white space, bottom tagline summarizing takeaway
|
||||
```
|
||||
|
||||
### Comparison
|
||||
|
||||
```
|
||||
@@ -227,6 +254,28 @@ COLORS: Left side Warm Orange (#ED8936), Right side Terracotta (#C05621),
|
||||
ELEMENTS: Bold icons, black outlines, centered divider line
|
||||
```
|
||||
|
||||
**Comparison + ink-notes + mono-ink palette** (Before/After, Traditional vs New):
|
||||
```
|
||||
Professional hand-drawn sketchnote comparison on pure white. Black ink line work
|
||||
with slight wobble, à la Mike Rohde sketchnoting.
|
||||
PALETTE: mono-ink — black ink dominant, sparse semantic accents
|
||||
COLORS: Pure White background (#FFFFFF), Near Black (#1A1A1A) for all outlines,
|
||||
text, figures, arrows; Coral Red (#E8655A) reserved for risks/gaps
|
||||
(left/Before side); Muted Teal (#5FA8A8) reserved for positives
|
||||
(right/After side). Color accents under 10% of canvas.
|
||||
LAYOUT: Left | Right split with vertical hand-drawn divider. Hand-lettered
|
||||
"Before" label (top-left) and "After" label (top-right).
|
||||
LEFT SIDE: Stick figure(s) with role label above, speech bubble showing the
|
||||
pain point, bulleted pain-point list in handwritten text.
|
||||
RIGHT SIDE: Stick figure(s) showing the new state, bulleted improvement list,
|
||||
small positive-action icons.
|
||||
BRIDGE: Curved hand-drawn "mindset shift" arrow bridging left → right with
|
||||
small inline label describing the shift.
|
||||
BOTTOM: Single-line hand-lettered tagline summarizing the takeaway.
|
||||
STYLE: Hand-lettered headings (bold, oversized), handwritten body annotations,
|
||||
generous white space, no computer fonts, no gradients, no shadows.
|
||||
```
|
||||
|
||||
### Framework
|
||||
|
||||
```
|
||||
@@ -259,6 +308,27 @@ COLORS: Soft Peach background (#FFECD2), nodes in Warm Orange (#ED8936),
|
||||
ELEMENTS: Rounded rectangles or circles for nodes, thick connecting lines
|
||||
```
|
||||
|
||||
**Framework + ink-notes + mono-ink palette** (command center, OS analogy):
|
||||
```
|
||||
Professional hand-drawn sketchnote framework on pure white. Black ink line work
|
||||
with slight wobble, à la Mike Rohde sketchnoting.
|
||||
PALETTE: mono-ink — black ink dominant, sparse semantic accents
|
||||
COLORS: Pure White background (#FFFFFF), Near Black (#1A1A1A) for all lines,
|
||||
text, figures; Dusty Lavender (#9B8AB5) for neutral category tags only;
|
||||
Coral Red (#E8655A) for emphasis sparingly. Color accents under 10%.
|
||||
STRUCTURE: Central rounded-rectangle frame as "the system" with hand-lettered
|
||||
title inside. Inner layer of labeled sub-components (node labels
|
||||
above each). Outer layer of feeder arrows from stick-figure
|
||||
operators/users with role labels.
|
||||
ELEMENTS: Stick figures at the edges with role tags ("Team Lead", "Operator"),
|
||||
wavy hand-drawn connector arrows with small inline labels, small
|
||||
doodle icons per component, dashed-border placeholder(s) for
|
||||
future/empty capabilities.
|
||||
BOTTOM: Single-line hand-lettered tagline.
|
||||
STYLE: Hand-lettered headings, handwritten annotations, generous white space,
|
||||
no computer fonts, no gradients.
|
||||
```
|
||||
|
||||
### Timeline
|
||||
|
||||
```
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
| `warm-knowledge` | `infographic` | `vector-illustration` | `warm` | Product showcases, team intros, feature cards, brand content |
|
||||
| `edu-visual` | `infographic` | `vector-illustration` | `macaron` | Knowledge summaries, concept explainers, educational articles |
|
||||
| `hand-drawn-edu` | `flowchart` | `sketch-notes` | `macaron` | Hand-drawn educational diagrams, process explainers, onboarding visuals |
|
||||
| `ink-notes-compare` | `comparison` | `ink-notes` | `mono-ink` | Before/After essays, Traditional vs New, OS-style comparisons, mindset-shift narratives |
|
||||
| `ink-notes-flow` | `flowchart` | `ink-notes` | `mono-ink` | Professional process explainers, workforce pipelines, hand-drawn technical walkthroughs |
|
||||
| `ink-notes-framework` | `framework` | `ink-notes` | `mono-ink` | System analogies, command-center diagrams, architecture-as-metaphor, tech manifestos |
|
||||
|
||||
### Data & Analysis
|
||||
|
||||
@@ -60,7 +63,8 @@ Use this table during Step 3 to recommend presets based on Step 2 content analys
|
||||
| Tutorial | `tutorial` | `process-flow`, `knowledge-base`, `edu-visual` |
|
||||
| Methodology / Framework | `system-design` | `architecture`, `process-flow` |
|
||||
| Data / Metrics | `data-report` | `versus`, `tech-explainer` |
|
||||
| Comparison / Review | `versus` | `business-compare`, `editorial-poster` |
|
||||
| Comparison / Review | `versus` | `business-compare`, `editorial-poster`, `ink-notes-compare` |
|
||||
| Manifesto / Mindset shift / Professional visual note | `ink-notes-compare` | `ink-notes-framework`, `ink-notes-flow` |
|
||||
| Narrative / Personal | `storytelling` | `lifestyle`, `evolution` |
|
||||
| Opinion / Editorial | `opinion-piece` | `cinematic`, `editorial-poster` |
|
||||
| Historical / Timeline | `history` | `evolution` |
|
||||
|
||||
@@ -43,6 +43,7 @@ Use Core Styles for most cases. See full Style Gallery below for granular contro
|
||||
| `sketch` | Raw pencil notebook style | Brainstorming, creative exploration |
|
||||
| `screen-print` | Bold poster art, halftone textures, limited colors | Opinion, editorial, cultural, cinematic |
|
||||
| `sketch-notes` | Soft hand-drawn warm notes | Educational, warm notes |
|
||||
| `ink-notes` | Black ink on pure white, sparse semantic accents, hand-lettered (à la Mike Rohde's sketchnoting) | Before/After essays, tech manifestos, framework analogies |
|
||||
| `vintage` | Aged parchment historical | Historical, heritage |
|
||||
|
||||
Full specifications: `references/styles/<style>.md`
|
||||
@@ -81,6 +82,7 @@ Full specifications: `references/styles/<style>.md`
|
||||
| How-to, steps, workflow, process, tutorial | flowchart | vector-illustration, notion |
|
||||
| Framework, model, architecture, principles | framework | blueprint, vector-illustration |
|
||||
| vs, pros/cons, before/after, alternatives | comparison | vector-illustration, notion |
|
||||
| Manifesto, mindset shift, workforce, OS, whiteboard, professional visual note | comparison / framework | ink-notes |
|
||||
| Story, emotion, journey, experience, personal | scene | warm, watercolor |
|
||||
| History, timeline, progress, evolution | timeline | elegant, warm |
|
||||
| Productivity, SaaS, tool, app, software | infographic | notion, vector-illustration |
|
||||
@@ -206,6 +208,7 @@ Palettes override a style's default colors. Combine any style with any palette:
|
||||
| `macaron` | Soft pastel blocks (blue, mint, lavender, peach) on warm cream | Educational, knowledge, tutorials |
|
||||
| `warm` | Warm earth tones (orange, terracotta, gold) on soft peach, no cool colors | Brand, product, lifestyle |
|
||||
| `neon` | Vibrant neon (pink, cyan, yellow) on dark purple | Gaming, retro, pop culture |
|
||||
| `mono-ink` | Black ink on pure white with sparse semantic accents (coral red, muted teal, dusty lavender) | Professional visual notes, Before/After, manifestos |
|
||||
|
||||
Full specifications: `references/palettes/<palette>.md`
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# ink-notes
|
||||
|
||||
Professional black-ink visual notes on pure white, in the tradition of Mike Rohde's sketchnoting
|
||||
|
||||
## Compared to sketch-notes
|
||||
|
||||
`ink-notes` and `sketch-notes` are distinct styles. Pick the right one:
|
||||
|
||||
| | `sketch-notes` | `ink-notes` |
|
||||
|---|---|---|
|
||||
| Background | Warm Off-White #FAF8F0 with paper grain | Pure White #FFFFFF, clean, no texture |
|
||||
| Palette | Soft warm accents (orange, mustard, sage, light blue) | Black ink dominant + sparse semantic accents |
|
||||
| Feel | Soft, warm, educational, approachable | Professional, structured, whiteboard-presentation |
|
||||
| Best For | Friendly tutorials, onboarding, casual explainers | Before/After essays, tech manifestos, framework analogies |
|
||||
|
||||
When in doubt: warm & friendly → `sketch-notes`. Disciplined & professional → `ink-notes`.
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Disciplined hand-drawn visual note. Confident black ink line work with slight wobble, hand-lettered typography, and sparse color accents used only for semantic emphasis. Feels like a skilled visual notetaker's whiteboard presentation — clean, structured, intentionally hand-drawn rather than decorative.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Pure White (#FFFFFF)
|
||||
- Texture: Clean, no grain, no tint
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Pure White | #FFFFFF | Canvas |
|
||||
| Primary Ink | Near Black | #1A1A1A | All lines, text, figures, arrows |
|
||||
| Accent Warm | Coral Red | #E8655A | Risk, problem, gap, emphasis |
|
||||
| Accent Cool | Muted Teal | #5FA8A8 | Positive, solution, "after" state |
|
||||
| Accent Neutral | Dusty Lavender | #9B8AB5 | Neutral tags, category labels |
|
||||
| Soft Fill | Pale Gray | #F0F0F0 | Subtle zone backgrounds (optional) |
|
||||
|
||||
Color accents must remain under 10% of canvas area and only carry semantic meaning. Black ink does the structural work.
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Black ink line work with intentional slight wobble on all strokes
|
||||
- Hand-lettered titles (bold, oversized) and handwritten body annotations
|
||||
- Simple stick-figure characters with expressive poses (pointing, thinking, walking)
|
||||
- Role labels above characters (e.g., "Tech Lead", "Compliance Officer")
|
||||
- Thought bubbles and speech bubbles with hand-drawn outlines
|
||||
- Rounded-rectangle frames for content groupings
|
||||
- Dashed-border rectangles for placeholder, "coming next", or empty states
|
||||
- Curvy hand-drawn arrows with small inline labels
|
||||
- Vertical or horizontal dividers between comparison zones ("Before" | "After")
|
||||
- "Mindset shift" curved arrow bridging two zones
|
||||
- Bottom tagline: single-line hand-lettered conclusion that points the takeaway
|
||||
- Stars, asterisks, underlines for emphasis — used sparingly
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Keep background pure white with no texture or tint
|
||||
- Let black ink dominate outlines, text, and figures
|
||||
- Use accent colors only for semantic highlighting
|
||||
- Keep all type hand-lettered — no computer-generated fonts
|
||||
- Maintain confident line quality (wobble, not mess)
|
||||
- Include a bottom tagline summarizing the main takeaway
|
||||
- Structure content into clear zones with visible dividers
|
||||
- Use dashed boxes for future, empty, or placeholder states
|
||||
|
||||
### Don't
|
||||
|
||||
- Use warm off-white or paper-textured backgrounds (that is sketch-notes' territory)
|
||||
- Fill large zones with color blocks
|
||||
- Use more than 3 accent colors per image
|
||||
- Use perfect geometric shapes — preserve hand-drawn wobble
|
||||
- Clutter with decorative doodles; every element must carry meaning
|
||||
- Use gradients, shadows, or computer-generated fonts
|
||||
|
||||
## Type Compatibility
|
||||
|
||||
| Type | Rating | Notes |
|
||||
|------|--------|-------|
|
||||
| comparison | ✓✓ | Best fit — Before/After, Traditional vs New, side-by-side contrasts |
|
||||
| framework | ✓✓ | OS-style command centers, layered architectures, organizational models |
|
||||
| flowchart | ✓✓ | Process explainers with labeled stages, workforce pipelines |
|
||||
| infographic | ✓ | Multi-zone technical summaries, manifesto-style posters |
|
||||
| timeline | ✓ | Hand-drawn horizontal arrow with era markers and milestones |
|
||||
| scene | ✗ | Not recommended — lacks scenic space |
|
||||
|
||||
## Best For
|
||||
|
||||
Product and engineering essays, tech manifestos, framework introductions, Before/After narratives, OS-level comparisons, workforce and organizational analogies, visual summaries of talks, thought-leadership articles
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import test from "node:test";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..", "..", "..");
|
||||
const scriptPath = path.join(repoRoot, "skills", "baoyu-article-illustrator", "scripts", "build-batch.ts");
|
||||
|
||||
async function makeFixture(): Promise<{
|
||||
root: string;
|
||||
outlinePath: string;
|
||||
promptsDir: string;
|
||||
outputPath: string;
|
||||
}> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "baoyu-article-illustrator-build-batch-"));
|
||||
const outlinePath = path.join(root, "outline.md");
|
||||
const promptsDir = path.join(root, "prompts");
|
||||
const outputPath = path.join(root, "batch.json");
|
||||
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
outlinePath,
|
||||
`## Illustration 1
|
||||
**Position**: demo
|
||||
**Purpose**: demo
|
||||
**Visual Content**: demo
|
||||
**Filename**: 01-demo.png
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(path.join(promptsDir, "01-demo.md"), "A demo prompt\n");
|
||||
|
||||
return { root, outlinePath, promptsDir, outputPath };
|
||||
}
|
||||
|
||||
async function runBuildBatch(args: string[]): Promise<void> {
|
||||
await execFileAsync(process.execPath, ["--import", "tsx", scriptPath, ...args], {
|
||||
cwd: repoRoot,
|
||||
});
|
||||
}
|
||||
|
||||
test("build-batch omits default model so baoyu-imagine can resolve env or EXTEND defaults", async () => {
|
||||
const fixture = await makeFixture();
|
||||
|
||||
await runBuildBatch([
|
||||
"--outline",
|
||||
fixture.outlinePath,
|
||||
"--prompts",
|
||||
fixture.promptsDir,
|
||||
"--output",
|
||||
fixture.outputPath,
|
||||
]);
|
||||
|
||||
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
|
||||
tasks: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
assert.equal(batch.tasks.length, 1);
|
||||
assert.equal(batch.tasks[0]?.provider, "replicate");
|
||||
assert.equal(Object.hasOwn(batch.tasks[0]!, "model"), false);
|
||||
});
|
||||
|
||||
test("build-batch preserves explicit model overrides", async () => {
|
||||
const fixture = await makeFixture();
|
||||
|
||||
await runBuildBatch([
|
||||
"--outline",
|
||||
fixture.outlinePath,
|
||||
"--prompts",
|
||||
fixture.promptsDir,
|
||||
"--output",
|
||||
fixture.outputPath,
|
||||
"--model",
|
||||
"acme/custom-model",
|
||||
]);
|
||||
|
||||
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
|
||||
tasks: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
assert.equal(batch.tasks[0]?.model, "acme/custom-model");
|
||||
});
|
||||
@@ -8,7 +8,7 @@ type CliArgs = {
|
||||
outputPath: string | null;
|
||||
imagesDir: string | null;
|
||||
provider: string;
|
||||
model: string;
|
||||
model: string | null;
|
||||
aspectRatio: string;
|
||||
quality: string;
|
||||
jobs: number | null;
|
||||
@@ -30,7 +30,7 @@ Options:
|
||||
--output <path> Path to output batch.json
|
||||
--images-dir <path> Directory for generated images
|
||||
--provider <name> Provider for baoyu-imagine batch tasks (default: replicate)
|
||||
--model <id> Model for baoyu-imagine batch tasks (default: google/nano-banana-pro)
|
||||
--model <id> Explicit model for baoyu-imagine batch tasks (default: resolved by baoyu-imagine config/env)
|
||||
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
|
||||
--quality <level> Quality for all tasks (default: 2k)
|
||||
--jobs <count> Recommended worker count metadata (optional)
|
||||
@@ -44,7 +44,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
outputPath: null,
|
||||
imagesDir: null,
|
||||
provider: "replicate",
|
||||
model: "google/nano-banana-pro",
|
||||
model: null,
|
||||
aspectRatio: "16:9",
|
||||
quality: "2k",
|
||||
jobs: null,
|
||||
@@ -132,15 +132,16 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const imageDir = args.imagesDir ?? path.dirname(args.outputPath);
|
||||
tasks.push({
|
||||
const task: Record<string, unknown> = {
|
||||
id: `illustration-${String(entry.index).padStart(2, "0")}`,
|
||||
promptFiles: [promptFile],
|
||||
image: path.join(imageDir, entry.filename),
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
ar: args.aspectRatio,
|
||||
quality: args.quality,
|
||||
});
|
||||
};
|
||||
if (args.model) task.model = args.model;
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = { tasks };
|
||||
|
||||
@@ -21,6 +21,10 @@ Technical, professional, precise
|
||||
- Technical schematics and diagrams
|
||||
- Geometric precision elements
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Architecture, system design, API, technical documentation, engineering, data analysis
|
||||
|
||||
@@ -21,6 +21,10 @@ Cinematic, premium, atmospheric
|
||||
- Silhouettes with backlit edges
|
||||
- Subtle gradient backgrounds
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Entertainment, premium brands, cinematic storytelling, dark mode, gaming, night themes
|
||||
|
||||
@@ -34,6 +34,10 @@ Choose ONE pair based on content mood. The two colors dominate the entire image:
|
||||
- Minimal use of third color (only for small highlights)
|
||||
- High contrast figure-ground relationships
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Movie posters, album covers, concert prints, dramatic announcements, cinematic content, bold branding, editorial covers, artistic campaigns
|
||||
|
||||
@@ -21,6 +21,10 @@ Natural, organic, grounded
|
||||
- Botanical illustrations
|
||||
- Earthy textures and natural patterns
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Nature, wellness, eco, organic, travel, sustainability, outdoor topics, slow living
|
||||
|
||||
@@ -21,6 +21,10 @@ Sophisticated, refined, understated luxury
|
||||
- Refined geometric patterns
|
||||
- Balanced, symmetrical compositions
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Business, professional, thought leadership, luxury, corporate communications
|
||||
|
||||
@@ -21,6 +21,10 @@ Soft macaron pastel color blocks on warm cream
|
||||
- Soft shadows, no hard edges
|
||||
- Gentle gradient transitions between zones
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, knowledge sharing, concept explainers, tutorials, tech summaries, onboarding materials
|
||||
|
||||
@@ -21,6 +21,10 @@ Clean, focused, essential
|
||||
- Single focal point emphasis
|
||||
- Stark contrast between elements
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Zen, focus, essential concepts, pure, simple, minimalist philosophy, clean design
|
||||
|
||||
@@ -21,6 +21,10 @@ Gentle, whimsical, soft
|
||||
- Soft shadows and gentle highlights
|
||||
- Storybook-style elements
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Fantasy, children, gentle content, creative, whimsical, casual, beginner guides
|
||||
|
||||
@@ -25,6 +25,10 @@ Nostalgic, vintage, classic
|
||||
- Pill-shaped clouds, small dots and stars
|
||||
- Classic icons and retro motifs
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
History, vintage, retro, classic, exploration, retrospectives, throwback content, creative proposals, educational
|
||||
|
||||
@@ -21,6 +21,10 @@ Energetic, bold, attention-grabbing
|
||||
- Dramatic lighting effects
|
||||
- High-energy visual compositions
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Product launch, gaming, promotion, event, marketing, announcements, brand showcases
|
||||
|
||||
@@ -21,6 +21,10 @@ Friendly, approachable, human-centered
|
||||
- Hearts, smiling faces, friendly icons
|
||||
- Warm gradient overlays
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best For
|
||||
|
||||
Personal growth, lifestyle, education, human stories, emotion, community
|
||||
|
||||
@@ -62,6 +62,7 @@ Visual composition:
|
||||
- Decorative: [palette-specific elements that reinforce content theme]
|
||||
|
||||
Color scheme: [primary, background, accent from palette definition, adjusted by mood]
|
||||
Color constraint: Color values (#hex) and color names are rendering guidance only — do NOT display color names, hex codes, or palette labels as visible text in the image.
|
||||
Rendering notes: [key characteristics from rendering definition — lines, texture, depth, element style]
|
||||
Type notes: [key characteristics from type definition]
|
||||
Palette notes: [key characteristics from palette definition]
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
name: baoyu-diagram
|
||||
description: Create professional, dark-themed SVG diagrams of any type — architecture diagrams, flowcharts, sequence diagrams, structural diagrams, mind maps, timelines, illustrative/conceptual diagrams, and more. Use this skill whenever the user asks for any kind of technical or conceptual diagram, visualization of a system, process flow, data flow, component relationship, network topology, decision tree, org chart, state machine, or any visual representation of structure/logic/process. Also trigger when the user says "画个图" "画一个架构图" "diagram" "flowchart" "sequence diagram" "draw me a ..." or uploads content and asks to visualize it. Output is always a standalone .svg file.
|
||||
---
|
||||
|
||||
# Diagram Generator
|
||||
|
||||
Create professional SVG diagrams across multiple diagram types. All output is a single self-contained `.svg` file with embedded styles and fonts.
|
||||
|
||||
## Supported Diagram Types
|
||||
|
||||
| Type | When to Use | Key Characteristics |
|
||||
|------|-------------|-------------------|
|
||||
| **Architecture** | System components & relationships | Grouped boxes, connection arrows, region boundaries |
|
||||
| **Flowchart** | Decision logic, process steps | Diamond decisions, rounded step boxes, directional flow |
|
||||
| **Sequence** | Time-ordered interactions between actors | Vertical lifelines, horizontal messages, activation bars |
|
||||
| **Structural** | Class diagrams, ER diagrams, org charts | Compartmented boxes, typed relationships (inheritance, composition) |
|
||||
| **Mind Map** | Brainstorming, topic exploration | Central node, radiating branches, organic layout |
|
||||
| **Timeline** | Chronological events | Horizontal/vertical axis, event markers, period spans |
|
||||
| **Illustrative** | Conceptual explanations, comparisons | Free-form layout, icons, annotations, visual metaphors |
|
||||
| **State Machine** | State transitions, lifecycle | Rounded state nodes, labeled transitions, start/end markers |
|
||||
| **Data Flow** | Data transformation pipelines | Process bubbles, data stores, external entities |
|
||||
|
||||
## Design System
|
||||
|
||||
### Color Palette
|
||||
|
||||
Semantic colors for component categories:
|
||||
|
||||
| Category | Fill (rgba) | Stroke | Use For |
|
||||
|----------|-------------|--------|---------|
|
||||
| Primary | `rgba(8, 51, 68, 0.4)` | `#22d3ee` (cyan) | Frontend, user-facing, inputs |
|
||||
| Secondary | `rgba(6, 78, 59, 0.4)` | `#34d399` (emerald) | Backend, services, processing |
|
||||
| Tertiary | `rgba(76, 29, 149, 0.4)` | `#a78bfa` (violet) | Database, storage, persistence |
|
||||
| Accent | `rgba(120, 53, 15, 0.3)` | `#fbbf24` (amber) | Cloud, infrastructure, regions |
|
||||
| Alert | `rgba(136, 19, 55, 0.4)` | `#fb7185` (rose) | Security, errors, warnings |
|
||||
| Connector | `rgba(251, 146, 60, 0.3)` | `#fb923c` (orange) | Buses, queues, middleware |
|
||||
| Neutral | `rgba(30, 41, 59, 0.5)` | `#94a3b8` (slate) | External, generic, unknown |
|
||||
| Highlight | `rgba(59, 130, 246, 0.3)` | `#60a5fa` (blue) | Active state, focus, current step |
|
||||
|
||||
For flowcharts and sequence diagrams, assign colors by role (actor, decision, process) rather than by technology.
|
||||
|
||||
### Typography
|
||||
|
||||
Use embedded SVG `@font-face` or system monospace fallback:
|
||||
|
||||
```svg
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
text { font-family: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace; }
|
||||
</style>
|
||||
```
|
||||
|
||||
Font sizes by role:
|
||||
- **Title:** 16px, weight 700
|
||||
- **Component name:** 11-12px, weight 600
|
||||
- **Sublabel / description:** 9px, weight 400, color `#94a3b8`
|
||||
- **Annotation / note:** 8px, weight 400
|
||||
- **Tiny label (on arrows):** 7-8px
|
||||
|
||||
### Core Visual Elements
|
||||
|
||||
**Background:** `#0f172a` (slate-900) with subtle grid:
|
||||
```svg
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="#0f172a"/>
|
||||
<rect width="100%" height="100%" fill="url(#grid)"/>
|
||||
```
|
||||
|
||||
**Arrowhead marker (standard):**
|
||||
```svg
|
||||
<marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
|
||||
</marker>
|
||||
```
|
||||
|
||||
**Arrowhead marker (colored) — create per-color as needed:**
|
||||
```svg
|
||||
<marker id="arrow-cyan" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#22d3ee"/>
|
||||
</marker>
|
||||
```
|
||||
|
||||
**Open arrowhead (for async/return messages):**
|
||||
```svg
|
||||
<marker id="arrow-open" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polyline points="0 0, 10 3.5, 0 7" fill="none" stroke="#64748b" stroke-width="1.5"/>
|
||||
</marker>
|
||||
```
|
||||
|
||||
### SVG Structure & Layering
|
||||
|
||||
Draw elements in this order to get correct z-ordering (SVG paints back-to-front):
|
||||
|
||||
1. Background fill + grid pattern
|
||||
2. Region/group boundaries (dashed outlines)
|
||||
3. Connection arrows and lines
|
||||
4. Opaque masking rects (same position as component boxes, `fill="#0f172a"`)
|
||||
5. Component boxes (semi-transparent fill + stroke)
|
||||
6. Text labels
|
||||
7. Legend (bottom-right or bottom area, outside all boundaries)
|
||||
8. Title block (top-left)
|
||||
|
||||
The opaque masking rect trick is essential — semi-transparent component fills will show arrows underneath without it:
|
||||
```svg
|
||||
<!-- Mask layer: opaque background to hide arrows -->
|
||||
<rect x="100" y="100" width="160" height="60" rx="6" fill="#0f172a"/>
|
||||
<!-- Visual layer: styled component -->
|
||||
<rect x="100" y="100" width="160" height="60" rx="6" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<text x="180" y="125" fill="white" font-size="11" font-weight="600" text-anchor="middle">API Gateway</text>
|
||||
<text x="180" y="141" fill="#94a3b8" font-size="9" text-anchor="middle">Kong / Nginx</text>
|
||||
```
|
||||
|
||||
### Spacing Rules
|
||||
|
||||
These prevent overlapping — follow them strictly:
|
||||
|
||||
- **Component box height:** 50-70px (standard), 80-120px (large/complex)
|
||||
- **Minimum gap between components:** 40px vertical, 30px horizontal
|
||||
- **Arrow label clearance:** 10px from any box edge
|
||||
- **Region boundary padding:** 20px inside edges around contained components
|
||||
- **Legend placement:** At least 20px below the lowest diagram element
|
||||
- **Title block:** 20px from top-left, outside diagram content area
|
||||
- **viewBox:** Always extend to fit all content + 30px padding on all sides
|
||||
|
||||
### Component Patterns
|
||||
|
||||
**Standard box (service/process):**
|
||||
```svg
|
||||
<rect x="X" y="Y" width="160" height="60" rx="6" fill="#0f172a"/>
|
||||
<rect x="X" y="Y" width="160" height="60" rx="6" fill="FILL" stroke="STROKE" stroke-width="1.5"/>
|
||||
<text x="CX" y="Y+24" fill="white" font-size="11" font-weight="600" text-anchor="middle">Name</text>
|
||||
<text x="CX" y="Y+40" fill="#94a3b8" font-size="9" text-anchor="middle">description</text>
|
||||
```
|
||||
|
||||
**Decision diamond (flowchart):**
|
||||
```svg
|
||||
<g transform="translate(CX, CY)">
|
||||
<polygon points="0,-35 50,0 0,35 -50,0" fill="#0f172a"/>
|
||||
<polygon points="0,-35 50,0 0,35 -50,0" fill="rgba(120,53,15,0.3)" stroke="#fbbf24" stroke-width="1.5"/>
|
||||
<text y="4" fill="white" font-size="10" font-weight="600" text-anchor="middle">Condition?</text>
|
||||
</g>
|
||||
```
|
||||
|
||||
**Database cylinder:**
|
||||
```svg
|
||||
<g transform="translate(X, Y)">
|
||||
<rect x="0" y="10" width="120" height="50" rx="2" fill="#0f172a"/>
|
||||
<ellipse cx="60" cy="10" rx="60" ry="12" fill="#0f172a"/>
|
||||
<ellipse cx="60" cy="60" rx="60" ry="12" fill="#0f172a"/>
|
||||
<rect x="0" y="10" width="120" height="50" fill="rgba(76,29,149,0.4)"/>
|
||||
<ellipse cx="60" cy="10" rx="60" ry="12" fill="rgba(76,29,149,0.4)" stroke="#a78bfa" stroke-width="1.5"/>
|
||||
<ellipse cx="60" cy="60" rx="60" ry="12" fill="rgba(76,29,149,0.4)" stroke="#a78bfa" stroke-width="1.5"/>
|
||||
<line x1="0" y1="10" x2="0" y2="60" stroke="#a78bfa" stroke-width="1.5"/>
|
||||
<line x1="120" y1="10" x2="120" y2="60" stroke="#a78bfa" stroke-width="1.5"/>
|
||||
<text x="60" y="40" fill="white" font-size="11" font-weight="600" text-anchor="middle">PostgreSQL</text>
|
||||
</g>
|
||||
```
|
||||
|
||||
**Region boundary:**
|
||||
```svg
|
||||
<rect x="X" y="Y" width="W" height="H" rx="12" fill="none" stroke="#fbbf24" stroke-width="1" stroke-dasharray="8,4"/>
|
||||
<text x="X+12" y="Y+16" fill="#fbbf24" font-size="9" font-weight="600">AWS us-east-1</text>
|
||||
```
|
||||
|
||||
**Security group:**
|
||||
```svg
|
||||
<rect x="X" y="Y" width="W" height="H" rx="8" fill="none" stroke="#fb7185" stroke-width="1" stroke-dasharray="4,4"/>
|
||||
<text x="X+10" y="Y+14" fill="#fb7185" font-size="8" font-weight="500">VPC / Security Group</text>
|
||||
```
|
||||
|
||||
## Type-Specific Layout Guidance
|
||||
|
||||
Determine this SKILL.md file's directory path as `{baseDir}`. Read the reference file for the specific diagram type before starting layout. Reference files are located at `{baseDir}/references/` and contain detailed layout algorithms and examples.
|
||||
|
||||
### Architecture Diagrams
|
||||
→ Read `{baseDir}/references/architecture.md`
|
||||
|
||||
Key points: left-to-right or top-to-bottom data flow. Group related services in region boundaries. Use buses/connectors between layers. Place databases at the bottom or right.
|
||||
|
||||
### Flowcharts
|
||||
→ Read `{baseDir}/references/flowchart.md`
|
||||
|
||||
Key points: top-to-bottom primary flow. Diamonds for decisions with Yes/No labels on exit arrows. Rounded rectangles for start/end. Use the Highlight color for the happy path.
|
||||
|
||||
### Sequence Diagrams
|
||||
→ Read `{baseDir}/references/sequence.md`
|
||||
|
||||
Key points: actors as boxes at top, vertical dashed lifelines, horizontal arrows for messages (solid=sync, dashed=return). Time flows downward. Activation bars show processing. Number messages if complex.
|
||||
|
||||
### Structural Diagrams
|
||||
→ Read `{baseDir}/references/structural.md`
|
||||
|
||||
Key points: compartmented boxes (name / attributes / methods for class diagrams). Relationship lines: solid with filled diamond=composition, solid with empty diamond=aggregation, dashed arrow=dependency, solid triangle=inheritance.
|
||||
|
||||
### Mind Maps
|
||||
Free-form radiating layout from a central concept. Use organic curves (`<path>` with cubic beziers) for branches. Vary branch colors using the palette. Larger font for central node, decreasing as you go outward.
|
||||
|
||||
### Timelines
|
||||
Horizontal or vertical axis line. Event markers as circles or diamonds on the axis. Description text offset to alternating sides to avoid overlap. Use color to categorize event types.
|
||||
|
||||
### State Machines
|
||||
Rounded-rect states with double-border for composite states. Filled circle for initial state, bullseye for final state. Curved arrows for self-transitions. Label all transitions with `event [guard] / action` format.
|
||||
|
||||
## Output Rules
|
||||
|
||||
1. Output a **single `.svg` file** — no external dependencies except the Google Fonts import
|
||||
2. Set `viewBox` to fit all content with 30px padding; do NOT set fixed `width`/`height` attributes (let the SVG scale responsively)
|
||||
3. Include `xmlns="http://www.w3.org/2000/svg"` on the root `<svg>` element
|
||||
4. Put all `<style>`, `<defs>`, markers, and patterns at the top of the SVG
|
||||
5. Use `text-anchor="middle"` for centered labels; ensure text doesn't overflow boxes
|
||||
6. **Chinese text support:** When labels contain Chinese characters, use `font-family: 'JetBrains Mono', 'Noto Sans SC', 'PingFang SC', sans-serif'` and increase box widths — CJK characters are wider
|
||||
7. **Save location:** If the input is a file, save to `{inputFileDir}/diagram/`. Otherwise save to `{projectDir}/diagram/{topic-slug}/`. Create the directory if it doesn't exist
|
||||
|
||||
## Script
|
||||
|
||||
Determine this SKILL.md file's directory path as `{baseDir}`. Script path: `{baseDir}/scripts/main.ts`.
|
||||
|
||||
Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun.
|
||||
|
||||
### SVG → @2x PNG
|
||||
|
||||
After saving the SVG, convert it to a @2x PNG:
|
||||
|
||||
```bash
|
||||
${BUN_X} {baseDir}/scripts/main.ts <svg-path> [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-s, --scale <n>` — Scale factor (default: 2)
|
||||
- `-o, --output <path>` — Custom output path (default: `<input>@2x.png`)
|
||||
- `--json` — JSON output
|
||||
|
||||
## Process
|
||||
|
||||
1. Identify the diagram type from the user's request
|
||||
2. Read the relevant reference file if one exists for that type
|
||||
3. Plan the layout: list all components, determine grouping and flow direction, calculate positions
|
||||
4. Write the SVG following the layering order above
|
||||
5. Verify spacing rules — no overlaps, legends outside boundaries, viewBox large enough
|
||||
6. Save the SVG file
|
||||
7. Run `${BUN_X} {baseDir}/scripts/main.ts <svg-path>` to generate @2x PNG
|
||||
8. Present both files to the user
|
||||
@@ -0,0 +1,74 @@
|
||||
# Architecture Diagram Layout
|
||||
|
||||
## Flow Direction
|
||||
|
||||
Choose one primary direction:
|
||||
- **Left-to-Right (LTR):** Best for data pipelines, request flows. Users/clients on left, data stores on right.
|
||||
- **Top-to-Bottom (TTB):** Best for layered architectures. Clients at top, infrastructure at bottom.
|
||||
|
||||
## Layout Algorithm
|
||||
|
||||
1. **Identify layers:** Group components by role (clients, gateways, services, data, infrastructure)
|
||||
2. **Assign columns (LTR) or rows (TTB):** One layer per column/row
|
||||
3. **Within each layer:** Stack components vertically (LTR) or horizontally (TTB), 40px gap minimum
|
||||
4. **Region boundaries:** Draw around groups that share infrastructure (e.g., "AWS us-east-1", "Kubernetes Cluster")
|
||||
5. **Connectors:** Route arrows between layers. For buses/queues between layers, place a thin connector bar in the gap.
|
||||
|
||||
## Typical Layer Structure (LTR)
|
||||
|
||||
```
|
||||
Col 1 (x=40) Col 2 (x=250) Col 3 (x=460) Col 4 (x=670)
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Client │────▶│ Gateway │─────▶│ Services │─────▶│ Database │
|
||||
│ Layer │ │ Layer │ │ Layer │ │ Layer │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
Column spacing: 200-220px between column starts. Adjust if components are wider.
|
||||
|
||||
## Typical Layer Structure (TTB)
|
||||
|
||||
```
|
||||
Row 1 (y=60): [ Browser ] [ Mobile App ] [ API Client ]
|
||||
Row 2 (y=160): [ Load Balancer / API Gateway ]
|
||||
Row 3 (y=280): [ Auth Svc ] [ User Svc ] [ Order Svc ]
|
||||
Row 4 (y=400): [ Redis ] [ PostgreSQL ] [ S3 Bucket ]
|
||||
```
|
||||
|
||||
Row spacing: 120-140px between row starts.
|
||||
|
||||
## Connection Routing
|
||||
|
||||
- Prefer straight horizontal or vertical lines
|
||||
- For connections that would cross components, use two-segment (L-shaped) paths:
|
||||
```svg
|
||||
<path d="M x1,y1 L midX,y1 L midX,y2" fill="none" stroke="#64748b" marker-end="url(#arrow)"/>
|
||||
```
|
||||
- For busy diagrams, use `stroke-opacity="0.6"` on less important connections
|
||||
- Label important connections with a text element near the midpoint
|
||||
|
||||
## Message Bus / Event Bus Pattern
|
||||
|
||||
When services communicate through a shared bus, draw it as a horizontal bar between the service layer:
|
||||
|
||||
```
|
||||
Services: [ Svc A ] [ Svc B ] [ Svc C ]
|
||||
│ │ │
|
||||
Bus: ════╪══════════════╪════════════╪═══════
|
||||
│ │ │
|
||||
Data: [ DB A ] [ DB B ] [ Cache ]
|
||||
```
|
||||
|
||||
Use the Connector color (orange) for the bus bar.
|
||||
|
||||
## Multi-Region / Multi-Cloud
|
||||
|
||||
Nest region boundaries:
|
||||
- Outer boundary: Cloud provider (AWS, GCP)
|
||||
- Inner boundary: Region or VPC
|
||||
- Innermost: Availability zones or subnets
|
||||
|
||||
Use different dash patterns to distinguish nesting levels:
|
||||
- Outer: `stroke-dasharray="12,4"`
|
||||
- Middle: `stroke-dasharray="8,4"`
|
||||
- Inner: `stroke-dasharray="4,4"`
|
||||
@@ -0,0 +1,60 @@
|
||||
# Flowchart Layout
|
||||
|
||||
## Shape Vocabulary
|
||||
|
||||
| Shape | Meaning | SVG Element |
|
||||
|-------|---------|-------------|
|
||||
| Rounded rect (large radius) | Start / End | `<rect rx="25">` |
|
||||
| Rectangle | Process / Action | `<rect rx="6">` |
|
||||
| Diamond | Decision | `<polygon>` rotated 45° |
|
||||
| Parallelogram | Input / Output | `<polygon>` with skew |
|
||||
| Cylinder | Data store | Ellipse + rect combo |
|
||||
|
||||
## Flow Direction
|
||||
|
||||
Primary flow: **top to bottom**. Branch flows go left/right from decisions.
|
||||
|
||||
## Layout Algorithm
|
||||
|
||||
1. **Identify the main path** (happy path / most common flow) — this runs straight down the center
|
||||
2. **Branch from decisions:** "Yes" continues down center, "No" branches right (or left if space is tight)
|
||||
3. **Merge paths:** Route branches back to the main path using L-shaped connectors
|
||||
4. **Loop-backs:** Route upward on the far left/right side of the diagram with curved paths
|
||||
|
||||
## Spacing
|
||||
|
||||
- Step-to-step vertical gap: 60-80px (enough for arrow + optional label)
|
||||
- Decision diamond height: 70px (point to point)
|
||||
- Decision diamond width: 100px (point to point)
|
||||
- Branch horizontal offset: 200px from center
|
||||
- Merge connector clearance: 20px from any box
|
||||
|
||||
## Decision Labels
|
||||
|
||||
Place "Yes" / "No" (or "True" / "False", "是" / "否") labels directly on the exit arrows, 10px from the diamond edge:
|
||||
|
||||
```svg
|
||||
<!-- Decision diamond at center (400, 200) -->
|
||||
<!-- Yes: downward -->
|
||||
<line x1="400" y1="235" x2="400" y2="300" stroke="#64748b" marker-end="url(#arrow)"/>
|
||||
<text x="412" y="260" fill="#34d399" font-size="8">Yes</text>
|
||||
|
||||
<!-- No: rightward -->
|
||||
<line x1="450" y1="200" x2="550" y2="200" stroke="#64748b" marker-end="url(#arrow)"/>
|
||||
<text x="480" y="193" fill="#fb7185" font-size="8">No</text>
|
||||
```
|
||||
|
||||
## Coloring Strategy
|
||||
|
||||
- **Start/End nodes:** Highlight color (blue)
|
||||
- **Process steps:** Primary (cyan) or Secondary (emerald)
|
||||
- **Decision diamonds:** Accent (amber) — they draw the eye naturally
|
||||
- **Error/exception paths:** Alert (rose) dashed arrows
|
||||
- **Happy path arrows:** Slightly brighter than branch arrows (`stroke-opacity` difference)
|
||||
|
||||
## Complex Flowcharts
|
||||
|
||||
For flowcharts with 10+ steps:
|
||||
- Group related steps into swim lanes (vertical columns with header bars)
|
||||
- Add a "phase" row header at the top of each swim lane
|
||||
- Use the region boundary pattern from Architecture for swim lanes
|
||||
@@ -0,0 +1,88 @@
|
||||
# Sequence Diagram Layout
|
||||
|
||||
## Core Elements
|
||||
|
||||
| Element | Visual | Description |
|
||||
|---------|--------|-------------|
|
||||
| Actor/Participant | Box at top + dashed vertical lifeline | Each entity in the interaction |
|
||||
| Sync message | Solid arrow → | Request or call |
|
||||
| Async message | Open arrowhead → | Fire-and-forget |
|
||||
| Return message | Dashed arrow ← | Response |
|
||||
| Activation bar | Narrow filled rect on lifeline | Entity is processing |
|
||||
| Self-message | Arrow looping back to same lifeline | Internal processing |
|
||||
| Note | Rounded rect with folded corner | Annotation |
|
||||
| Alt/Opt frame | Dashed boundary with label tab | Conditional block |
|
||||
| Loop frame | Dashed boundary with "loop" tab | Repetition |
|
||||
|
||||
## Layout Algorithm
|
||||
|
||||
1. **Place actors** horizontally across the top, evenly spaced (150-200px apart)
|
||||
2. **Draw lifelines** as vertical dashed lines from each actor box downward
|
||||
3. **Place messages** as horizontal arrows between lifelines, top to bottom in time order
|
||||
4. **Vertical spacing** between messages: 40-50px
|
||||
5. **Activation bars:** 10px wide, centered on lifeline, spanning from incoming to outgoing message
|
||||
|
||||
## Actor Box
|
||||
|
||||
```svg
|
||||
<!-- Actor box -->
|
||||
<rect x="X" y="20" width="130" height="45" rx="6" fill="#0f172a"/>
|
||||
<rect x="X" y="20" width="130" height="45" rx="6" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<text x="CX" y="47" fill="white" font-size="11" font-weight="600" text-anchor="middle">Actor Name</text>
|
||||
|
||||
<!-- Lifeline -->
|
||||
<line x1="CX" y1="65" x2="CX" y2="BOTTOM" stroke="#334155" stroke-width="1" stroke-dasharray="6,4"/>
|
||||
```
|
||||
|
||||
## Message Arrows
|
||||
|
||||
```svg
|
||||
<!-- Sync message (solid arrow) -->
|
||||
<line x1="FROM_CX" y1="Y" x2="TO_CX" y2="Y" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<text x="MID_X" y="Y-8" fill="#e2e8f0" font-size="9" text-anchor="middle">methodCall()</text>
|
||||
|
||||
<!-- Return message (dashed arrow, reversed direction) -->
|
||||
<line x1="TO_CX" y1="Y" x2="FROM_CX" y2="Y" stroke="#64748b" stroke-width="1" stroke-dasharray="6,3" marker-end="url(#arrow)"/>
|
||||
<text x="MID_X" y="Y-8" fill="#94a3b8" font-size="8" text-anchor="middle" font-style="italic">response</text>
|
||||
|
||||
<!-- Self-message (loop arrow) -->
|
||||
<path d="M CX,Y L CX+40,Y L CX+40,Y+25 L CX,Y+25" fill="none" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<text x="CX+45" y="Y+15" fill="#e2e8f0" font-size="8">process()</text>
|
||||
```
|
||||
|
||||
## Activation Bar
|
||||
|
||||
```svg
|
||||
<rect x="CX-5" y="START_Y" width="10" height="H" rx="2" fill="rgba(8,51,68,0.6)" stroke="#22d3ee" stroke-width="1"/>
|
||||
```
|
||||
|
||||
## Conditional / Loop Frames
|
||||
|
||||
```svg
|
||||
<!-- Frame boundary -->
|
||||
<rect x="X" y="Y" width="W" height="H" rx="4" fill="none" stroke="#64748b" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<!-- Frame label tab -->
|
||||
<rect x="X" y="Y" width="50" height="18" rx="4" fill="rgba(30,41,59,0.8)" stroke="#64748b" stroke-width="1"/>
|
||||
<text x="X+25" y="Y+13" fill="#94a3b8" font-size="8" font-weight="600" text-anchor="middle">alt</text>
|
||||
<!-- Condition text -->
|
||||
<text x="X+60" y="Y+13" fill="#94a3b8" font-size="8" font-style="italic">[condition]</text>
|
||||
<!-- Divider line for else -->
|
||||
<line x1="X" y1="MID_Y" x2="X+W" y2="MID_Y" stroke="#64748b" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="X+10" y="MID_Y+13" fill="#94a3b8" font-size="8" font-style="italic">[else]</text>
|
||||
```
|
||||
|
||||
## Numbering
|
||||
|
||||
For complex sequences (8+ messages), number each message:
|
||||
|
||||
```svg
|
||||
<circle cx="FROM_CX-15" cy="Y" r="8" fill="rgba(59,130,246,0.3)" stroke="#60a5fa" stroke-width="1"/>
|
||||
<text x="FROM_CX-15" y="Y+3" fill="#60a5fa" font-size="7" font-weight="600" text-anchor="middle">1</text>
|
||||
```
|
||||
|
||||
## Color Assignment
|
||||
|
||||
Assign each actor a distinct color from the palette. Use that color for:
|
||||
- Actor box stroke
|
||||
- Activation bar on that lifeline
|
||||
- Outgoing arrows from that actor (optional, for visual clarity in complex diagrams)
|
||||
@@ -0,0 +1,100 @@
|
||||
# Structural Diagram Layout
|
||||
|
||||
Covers: class diagrams, ER diagrams, component diagrams, package diagrams, org charts.
|
||||
|
||||
## Class Diagram
|
||||
|
||||
### Class Box (3-compartment)
|
||||
|
||||
```svg
|
||||
<g transform="translate(X, Y)">
|
||||
<!-- Mask -->
|
||||
<rect width="180" height="120" rx="6" fill="#0f172a"/>
|
||||
<!-- Box -->
|
||||
<rect width="180" height="120" rx="6" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<!-- Class name compartment -->
|
||||
<text x="90" y="24" fill="white" font-size="11" font-weight="700" text-anchor="middle">ClassName</text>
|
||||
<!-- Divider 1 -->
|
||||
<line x1="0" y1="35" x2="180" y2="35" stroke="#22d3ee" stroke-width="0.5" stroke-opacity="0.5"/>
|
||||
<!-- Attributes -->
|
||||
<text x="10" y="52" fill="#94a3b8" font-size="8">- id: int</text>
|
||||
<text x="10" y="64" fill="#94a3b8" font-size="8">- name: string</text>
|
||||
<!-- Divider 2 -->
|
||||
<line x1="0" y1="75" x2="180" y2="75" stroke="#22d3ee" stroke-width="0.5" stroke-opacity="0.5"/>
|
||||
<!-- Methods -->
|
||||
<text x="10" y="92" fill="#94a3b8" font-size="8">+ getName(): string</text>
|
||||
<text x="10" y="104" fill="#94a3b8" font-size="8">+ setName(s: string)</text>
|
||||
</g>
|
||||
```
|
||||
|
||||
For abstract classes, italicize the class name. For interfaces, add `«interface»` above the name in smaller font.
|
||||
|
||||
### Relationship Lines
|
||||
|
||||
| Relationship | Line Style | Arrow/End |
|
||||
|-------------|------------|-----------|
|
||||
| Inheritance | Solid | Empty triangle (▷) pointing to parent |
|
||||
| Implementation | Dashed | Empty triangle pointing to interface |
|
||||
| Composition | Solid | Filled diamond (◆) at owner end |
|
||||
| Aggregation | Solid | Empty diamond (◇) at owner end |
|
||||
| Dependency | Dashed | Open arrowhead at dependency target |
|
||||
| Association | Solid | Open arrowhead or none |
|
||||
|
||||
**Markers:**
|
||||
|
||||
```svg
|
||||
<!-- Inheritance triangle -->
|
||||
<marker id="inherit" markerWidth="12" markerHeight="10" refX="12" refY="5" orient="auto">
|
||||
<polygon points="0 0, 12 5, 0 10" fill="#0f172a" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
</marker>
|
||||
|
||||
<!-- Composition diamond -->
|
||||
<marker id="composition" markerWidth="12" markerHeight="8" refX="0" refY="4" orient="auto">
|
||||
<polygon points="0 4, 6 0, 12 4, 6 8" fill="#94a3b8"/>
|
||||
</marker>
|
||||
|
||||
<!-- Aggregation diamond -->
|
||||
<marker id="aggregation" markerWidth="12" markerHeight="8" refX="0" refY="4" orient="auto">
|
||||
<polygon points="0 4, 6 0, 12 4, 6 8" fill="#0f172a" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
</marker>
|
||||
```
|
||||
|
||||
### Cardinality Labels
|
||||
|
||||
Place at each end of the relationship line, offset 5-8px from the box edge:
|
||||
|
||||
```svg
|
||||
<text x="X" y="Y" fill="#94a3b8" font-size="8">1..*</text>
|
||||
```
|
||||
|
||||
## ER Diagram
|
||||
|
||||
Similar to class diagrams but:
|
||||
- Use 2-compartment boxes (entity name + attributes)
|
||||
- Mark primary keys with `PK` prefix and bold
|
||||
- Mark foreign keys with `FK` prefix
|
||||
- Relationship lines use crow's foot notation:
|
||||
|
||||
```svg
|
||||
<!-- One end (single line) -->
|
||||
<line x1="X1" y1="Y" x2="X1+15" y2="Y" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
<!-- Many end (crow's foot) -->
|
||||
<line x1="X2-15" y1="Y-6" x2="X2" y2="Y" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
<line x1="X2-15" y1="Y+6" x2="X2" y2="Y" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
<line x1="X2-15" y1="Y" x2="X2" y2="Y" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
```
|
||||
|
||||
## Org Chart
|
||||
|
||||
- Top-down tree layout
|
||||
- Root at top center
|
||||
- Each level evenly spaced (100-120px vertical gap)
|
||||
- Siblings evenly distributed horizontally
|
||||
- Connection lines: vertical from parent bottom center to horizontal bar, then vertical down to each child top center
|
||||
- Use color to indicate departments or hierarchy levels
|
||||
|
||||
## Layout Tips
|
||||
|
||||
- Start by counting the widest level to determine total diagram width
|
||||
- Center the tree horizontally in the viewBox
|
||||
- For deep trees (5+ levels), consider horizontal layout instead
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bun
|
||||
import { existsSync, readFileSync, mkdirSync } from "fs";
|
||||
import { basename, dirname, extname, join, resolve } from "path";
|
||||
|
||||
interface Options {
|
||||
input: string;
|
||||
output?: string;
|
||||
scale: number;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
function parseViewBox(svg: string): { width: number; height: number } | null {
|
||||
const vb = svg.match(/viewBox\s*=\s*"([^"]+)"/);
|
||||
if (vb) {
|
||||
const parts = vb[1].split(/[\s,]+/).map(Number);
|
||||
if (parts.length >= 4 && parts[2] > 0 && parts[3] > 0) return { width: parts[2], height: parts[3] };
|
||||
}
|
||||
const w = svg.match(/\bwidth\s*=\s*"(\d+(?:\.\d+)?)"/);
|
||||
const h = svg.match(/\bheight\s*=\s*"(\d+(?:\.\d+)?)"/);
|
||||
if (w && h) return { width: Number(w[1]), height: Number(h[1]) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOutputPath(input: string, scale: number, custom?: string): string {
|
||||
if (custom) return resolve(custom);
|
||||
const dir = dirname(input);
|
||||
const base = basename(input, extname(input));
|
||||
const suffix = scale === 1 ? "" : `@${scale}x`;
|
||||
return join(dir, `${base}${suffix}.png`);
|
||||
}
|
||||
|
||||
async function convert(input: string, opts: Options): Promise<{ output: string; width: number; height: number }> {
|
||||
const svg = readFileSync(input);
|
||||
const svgStr = svg.toString("utf-8");
|
||||
const dims = parseViewBox(svgStr);
|
||||
if (!dims) throw new Error("Cannot determine SVG dimensions from viewBox or width/height attributes");
|
||||
|
||||
const width = Math.round(dims.width * opts.scale);
|
||||
const height = Math.round(dims.height * opts.scale);
|
||||
|
||||
const sharp = (await import("sharp")).default;
|
||||
const output = getOutputPath(input, opts.scale, opts.output);
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
|
||||
await sharp(svg, { density: 72 * opts.scale })
|
||||
.resize(width, height)
|
||||
.png()
|
||||
.toFile(output);
|
||||
|
||||
return { output, width, height };
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: bun main.ts <input.svg> [options]
|
||||
|
||||
Convert SVG to @2x PNG.
|
||||
|
||||
Options:
|
||||
-o, --output <path> Output path (default: <input>@2x.png)
|
||||
-s, --scale <n> Scale factor (default: 2)
|
||||
--json JSON output
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Options | null {
|
||||
const opts: Options = { input: "", scale: 2, json: false };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "-h" || arg === "--help") { printHelp(); process.exit(0); }
|
||||
else if (arg === "-o" || arg === "--output") opts.output = args[++i];
|
||||
else if (arg === "-s" || arg === "--scale") {
|
||||
const s = Number(args[++i]);
|
||||
if (isNaN(s) || s <= 0) { console.error(`Invalid scale: ${args[i]}`); return null; }
|
||||
opts.scale = s;
|
||||
} else if (arg === "--json") opts.json = true;
|
||||
else if (!arg.startsWith("-") && !opts.input) opts.input = arg;
|
||||
}
|
||||
if (!opts.input) { console.error("Error: Input SVG file required"); printHelp(); return null; }
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
if (!opts) process.exit(1);
|
||||
|
||||
const input = resolve(opts.input);
|
||||
if (!existsSync(input)) { console.error(`Error: ${input} not found`); process.exit(1); }
|
||||
if (extname(input).toLowerCase() !== ".svg") { console.error("Error: Input must be an SVG file"); process.exit(1); }
|
||||
|
||||
try {
|
||||
const r = await convert(input, opts);
|
||||
if (opts.json) console.log(JSON.stringify({ input, ...r }, null, 2));
|
||||
else console.log(`${input} → ${r.output} (${r.width}×${r.height})`);
|
||||
} catch (e) {
|
||||
console.error(`Error: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -22,7 +22,7 @@ Vibrant neon colors on dark background. High-energy, futuristic, eye-catching.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Vibrant neon color palette on dark background. Colors should glow against the dark base. High contrast, futuristic feel. Use neon sparingly — too many glowing elements become chaotic. Let dark background breathe.
|
||||
Vibrant neon color palette on dark background. Colors should glow against the dark base. High contrast, futuristic feel. Use neon sparingly — too many glowing elements become chaotic. Let dark background breathe. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Warm earth tones on soft peach background. Cozy, inviting, no cool colors.
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Warm-only color palette, no cool colors (no blue, green, purple). Earth tones throughout. Evokes comfort, warmth, and trust. All colors should feel like autumn sunlight.
|
||||
Warm-only color palette, no cool colors (no blue, green, purple). Earth tones throughout. Evokes comfort, warmth, and trust. All colors should feel like autumn sunlight. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
|
||||
+101
-19
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-imagine
|
||||
description: AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, MiniMax, 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.4
|
||||
description: AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, Z.AI GLM-Image, MiniMax, 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.57.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-imagine
|
||||
@@ -13,7 +13,7 @@ metadata:
|
||||
|
||||
# Image Generation (AI SDK)
|
||||
|
||||
Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||
Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Z.AI GLM-Image, MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||
|
||||
## Script Directory
|
||||
|
||||
@@ -57,7 +57,7 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md") { "user" }
|
||||
|
||||
Legacy compatibility: if `.baoyu-skills/baoyu-image-gen/EXTEND.md` exists and the new path does not, runtime renames it to `baoyu-imagine`. If both files exist, runtime leaves them unchanged and uses the new path.
|
||||
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | OpenAI image API dialect | Default models | Batch worker cap | Provider-specific batch limits
|
||||
|
||||
Schema: `references/config/preferences-schema.md`
|
||||
|
||||
@@ -76,7 +76,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
||||
# From prompt files
|
||||
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate supported families, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||
|
||||
# With reference images (explicit provider/model)
|
||||
@@ -103,6 +103,12 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9
|
||||
# DashScope legacy Qwen fixed-size model
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
|
||||
|
||||
# Z.AI GLM-image
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
|
||||
|
||||
# Z.AI GLM-image with explicit custom size
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A science illustration with labels" --image out.png --provider zai --model glm-image --size 1472x1088
|
||||
|
||||
# MiniMax
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||
|
||||
@@ -112,11 +118,14 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A girl stands by the library window
|
||||
# MiniMax with custom size (documented for image-01)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic poster" --image out.jpg --provider minimax --model image-01 --size 1536x1024
|
||||
|
||||
# Replicate (google/nano-banana-pro)
|
||||
# Replicate (default: google/nano-banana-2)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
# Replicate with specific model
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||
# Replicate Seedream 4.5
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
|
||||
|
||||
# Replicate Wan 2.7 Image Pro
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A concept frame" --image out.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
|
||||
|
||||
# Batch mode with saved prompt files
|
||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
|
||||
@@ -136,7 +145,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json
|
||||
"promptFiles": ["prompts/hero.md"],
|
||||
"image": "out/hero.png",
|
||||
"provider": "replicate",
|
||||
"model": "google/nano-banana-pro",
|
||||
"model": "google/nano-banana-2",
|
||||
"ar": "16:9",
|
||||
"quality": "2k"
|
||||
},
|
||||
@@ -161,14 +170,15 @@ 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\|azure\|openrouter\|dashscope\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; Azure: deployment name such as `gpt-image-1.5` or `image-prod`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`; MiniMax: `image-01`) |
|
||||
| `--provider google\|openai\|azure\|openrouter\|dashscope\|zai\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; Azure: deployment name such as `gpt-image-1.5` or `image-prod`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`; Z.AI: `glm-image`; MiniMax: `image-01`) |
|
||||
| `--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, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate, MiniMax subject-reference, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 |
|
||||
| `--n <count>` | Number of images |
|
||||
| `--imageApiDialect openai-native\|ratio-metadata` | OpenAI-compatible image API dialect. Use `ratio-metadata` when the endpoint is OpenAI-compatible but expects aspect-ratio `size` plus `metadata.resolution` instead of pixel `size` |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate supported families, MiniMax subject-reference, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 |
|
||||
| `--n <count>` | Number of images. Replicate currently supports only `--n 1` because this path saves exactly one output image |
|
||||
| `--json` | JSON output |
|
||||
|
||||
## Environment Variables
|
||||
@@ -180,6 +190,8 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `OPENROUTER_API_KEY` | OpenRouter API key |
|
||||
| `GOOGLE_API_KEY` | Google API key |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||
| `ZAI_API_KEY` | Z.AI API key |
|
||||
| `BIGMODEL_API_KEY` | Backward-compatible alias for Z.AI API key |
|
||||
| `MINIMAX_API_KEY` | MiniMax API key |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
||||
@@ -191,11 +203,14 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `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: `qwen-image-2.0-pro`) |
|
||||
| `ZAI_IMAGE_MODEL` | Z.AI model override (default: `glm-image`) |
|
||||
| `BIGMODEL_IMAGE_MODEL` | Backward-compatible alias for Z.AI model override |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax model override (default: `image-01`) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-2) |
|
||||
| `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 |
|
||||
| `OPENAI_IMAGE_API_DIALECT` | OpenAI-compatible image API dialect override (`openai-native` or `ratio-metadata`) |
|
||||
| `AZURE_OPENAI_BASE_URL` | Azure resource endpoint or deployment endpoint |
|
||||
| `AZURE_API_VERSION` | Azure image API version (default: `2025-04-01-preview`) |
|
||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `https://openrouter.ai/api/v1`) |
|
||||
@@ -203,6 +218,8 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||
| `ZAI_BASE_URL` | Custom Z.AI endpoint (default: `https://api.z.ai/api/paas/v4`) |
|
||||
| `BIGMODEL_BASE_URL` | Backward-compatible alias for Z.AI endpoint |
|
||||
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint (default: `https://api.minimax.io`) |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
||||
@@ -227,6 +244,22 @@ For Azure, `--model` / `default_model.azure` should be the Azure deployment name
|
||||
|
||||
**EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins.
|
||||
|
||||
### OpenAI-Compatible Gateway Dialects
|
||||
|
||||
`provider=openai` means the auth and routing entrypoint is OpenAI-compatible. It does **not** guarantee that the upstream image API uses OpenAI native image-request semantics.
|
||||
|
||||
Use `default_image_api_dialect` in `EXTEND.md`, `OPENAI_IMAGE_API_DIALECT`, or `--imageApiDialect` when the endpoint expects a different wire format:
|
||||
|
||||
- `openai-native`: Sends pixel `size` such as `1536x1024` and native OpenAI quality fields when supported
|
||||
- `ratio-metadata`: Sends aspect-ratio `size` such as `16:9` and maps quality/size intent into `metadata.resolution` (`1K|2K|4K`) plus `metadata.orientation`
|
||||
|
||||
Recommended use:
|
||||
|
||||
- OpenAI native Images API or strict clones: keep `openai-native`
|
||||
- OpenAI-compatible gateways in front of Gemini or similar models: try `ratio-metadata`
|
||||
|
||||
Current limitation: `ratio-metadata` only applies to text-to-image generation. Reference-image edit flows still require `openai-native` or another provider with first-class edit support.
|
||||
|
||||
**Agent MUST display model info** before each generation:
|
||||
- Show: `Using [provider] / [model]`
|
||||
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
|
||||
@@ -277,6 +310,32 @@ Official references:
|
||||
- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image)
|
||||
- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
|
||||
|
||||
### Z.AI Models
|
||||
|
||||
Use `--model glm-image` or set `default_model.zai` / `ZAI_IMAGE_MODEL` when the user wants GLM-image output.
|
||||
|
||||
Official Z.AI image model options currently documented in the sync image API:
|
||||
|
||||
- `glm-image` (recommended default)
|
||||
- Text-to-image only in `baoyu-imagine`
|
||||
- Native `quality` options are `hd` and `standard`; this skill maps `2k -> hd` and `normal -> standard`
|
||||
- Recommended sizes: `1280x1280`, `1568x1056`, `1056x1568`, `1472x1088`, `1088x1472`, `1728x960`, `960x1728`
|
||||
- Custom `--size` requires width and height between `1024` and `2048`, divisible by `32`, with total pixels <= `2^22`
|
||||
- `cogview-4-250304`
|
||||
- Legacy Z.AI image model family exposed by the same endpoint
|
||||
- Custom `--size` requires width and height between `512` and `2048`, divisible by `16`, with total pixels <= `2^21`
|
||||
|
||||
Notes:
|
||||
|
||||
- The official sync API returns a temporary image URL; `baoyu-imagine` downloads that URL and writes the image locally
|
||||
- `--ref` is not supported for Z.AI in this skill yet
|
||||
- The sync API currently returns a single image, so `--n > 1` is rejected
|
||||
|
||||
Official references:
|
||||
|
||||
- [GLM-Image Guide](https://docs.z.ai/guides/image/glm-image)
|
||||
- [Generate Image API](https://docs.z.ai/api-reference/image/generate-image)
|
||||
|
||||
### MiniMax Models
|
||||
|
||||
Use `--model image-01` or set `default_model.minimax` / `MINIMAX_IMAGE_MODEL` when the user wants MiniMax image generation.
|
||||
@@ -322,10 +381,33 @@ Notes:
|
||||
|
||||
### Replicate Models
|
||||
|
||||
Supported model formats:
|
||||
Replicate support in `baoyu-imagine` is intentionally scoped to the model families that the tool can validate locally and save without dropping outputs:
|
||||
|
||||
- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro`
|
||||
- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:<version>`
|
||||
- `google/nano-banana*` (default: `google/nano-banana-2`)
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `aspect_ratio`, `resolution`, and `output_format`
|
||||
- `--size <WxH>` is accepted only as a shorthand for a documented aspect ratio plus `1K` / `2K`
|
||||
- `bytedance/seedream-4.5`
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `size`, `aspect_ratio`, and `image_input`
|
||||
- Local validation blocks unsupported `1K` requests before the API call
|
||||
- `bytedance/seedream-5-lite`
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `size`, `aspect_ratio`, and `image_input`
|
||||
- Local validation currently accepts `2K` / `3K` only
|
||||
- `wan-video/wan-2.7-image`
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `size` and `images`
|
||||
- Max output size is 2K
|
||||
- `wan-video/wan-2.7-image-pro`
|
||||
- Supports prompt-only and reference-image generation
|
||||
- Uses Replicate `size` and `images`
|
||||
- 4K is allowed only for text-to-image; local validation blocks `4K + --ref`
|
||||
|
||||
Guardrails:
|
||||
|
||||
- Replicate currently supports only single-output save semantics in this tool. Keep `--n 1`.
|
||||
- If a Replicate model is outside the compatibility list above, `baoyu-imagine` only treats it as prompt-only and rejects advanced local options instead of guessing a nano-banana-style schema.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -342,7 +424,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then Azure, then OpenRouter, then Replicate, then Seedream, then MiniMax (MiniMax subject reference is more specialized toward character/portrait consistency)
|
||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `azure`, `openrouter`, `replicate`, `seedream`, or `minimax`)
|
||||
3. Only one API key available → use that provider
|
||||
4. Multiple available → default to Google
|
||||
4. Multiple available → default to Google, then OpenAI, Azure, OpenRouter, DashScope, Z.AI, MiniMax, Replicate, Jimeng, Seedream
|
||||
|
||||
## Quality Presets
|
||||
|
||||
@@ -360,7 +442,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
- Google multimodal: uses `imageConfig.aspectRatio`
|
||||
- OpenAI: maps to closest supported size
|
||||
- OpenRouter: sends `imageGenerationOptions.aspect_ratio`; if only `--size <WxH>` is given, aspect ratio is inferred automatically
|
||||
- Replicate: passes `aspect_ratio` to model; when `--ref` is provided without `--ar`, defaults to `match_input_image`
|
||||
- Replicate: behavior is model-family-specific. `google/nano-banana*` uses `aspect_ratio`; `bytedance/seedream-*` uses documented Replicate aspect ratios; Wan 2.7 maps `--ar` to a concrete `size`
|
||||
- MiniMax: sends official `aspect_ratio` values directly; if `--size <WxH>` is given without `--ar`, `width` / `height` are sent for `image-01`
|
||||
|
||||
## Generation Mode
|
||||
|
||||
@@ -53,10 +53,12 @@ options:
|
||||
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
|
||||
- label: "DashScope"
|
||||
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
|
||||
- label: "Z.AI"
|
||||
description: "GLM-image, strong poster and text-heavy image generation"
|
||||
- label: "MiniMax"
|
||||
description: "MiniMax image generation with subject-reference character workflows"
|
||||
- label: "Replicate"
|
||||
description: "Community models - nano-banana-pro, flexible model selection"
|
||||
description: "Curated Replicate image families - nano-banana-2, Seedream, and Wan image models"
|
||||
```
|
||||
|
||||
### Question 2: Default Google Model
|
||||
@@ -119,6 +121,20 @@ options:
|
||||
description: "Faster variant, use aspect ratio instead of custom size"
|
||||
```
|
||||
|
||||
### Question 2e: Default Z.AI Model
|
||||
|
||||
Only show if user selected Z.AI.
|
||||
|
||||
```yaml
|
||||
header: "Z.AI Model"
|
||||
question: "Default Z.AI image generation model?"
|
||||
options:
|
||||
- label: "glm-image (Recommended)"
|
||||
description: "Best default for posters, diagrams, and text-heavy images"
|
||||
- label: "cogview-4-250304"
|
||||
description: "Legacy Z.AI image model on the same endpoint"
|
||||
```
|
||||
|
||||
### Question 3: Default Quality
|
||||
|
||||
```yaml
|
||||
@@ -159,17 +175,21 @@ default_provider: [selected provider or null]
|
||||
default_quality: [selected quality]
|
||||
default_aspect_ratio: null
|
||||
default_image_size: null
|
||||
default_image_api_dialect: null
|
||||
default_model:
|
||||
google: [selected google model or null]
|
||||
openai: null
|
||||
azure: [selected azure deployment or null]
|
||||
openrouter: [selected openrouter model or null]
|
||||
dashscope: null
|
||||
zai: [selected Z.AI model or null]
|
||||
minimax: [selected minimax model or null]
|
||||
replicate: null
|
||||
---
|
||||
```
|
||||
|
||||
If the user selects `OpenAI` but says their endpoint is only OpenAI-compatible and fronts another image model family, save `default_image_api_dialect: ratio-metadata` when they explicitly confirm the gateway expects aspect-ratio `size` plus metadata-based resolution. Otherwise leave it `null` / `openai-native`.
|
||||
|
||||
## Flow 2: EXTEND.md Exists, Model Null
|
||||
|
||||
When EXTEND.md exists but `default_model.[current_provider]` is null, ask ONLY the model question for the current provider.
|
||||
@@ -257,16 +277,38 @@ Notes for DashScope setup:
|
||||
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
|
||||
- In `baoyu-imagine`, `quality` is a compatibility preset. It is not a native DashScope parameter.
|
||||
|
||||
### Z.AI Model Selection
|
||||
|
||||
```yaml
|
||||
header: "Z.AI Model"
|
||||
question: "Choose a default Z.AI image generation model?"
|
||||
options:
|
||||
- label: "glm-image (Recommended)"
|
||||
description: "Current flagship image model with better text rendering and poster layouts"
|
||||
- label: "cogview-4-250304"
|
||||
description: "Legacy model on the sync image endpoint"
|
||||
```
|
||||
|
||||
Notes for Z.AI setup:
|
||||
|
||||
- Prefer `glm-image` for posters, diagrams, and Chinese/English text-heavy layouts.
|
||||
- In `baoyu-imagine`, Z.AI currently exposes text-to-image only; reference images are not wired for this provider.
|
||||
- The sync Z.AI image API returns a downloadable image URL, which the runtime saves locally after download.
|
||||
|
||||
### Replicate Model Selection
|
||||
|
||||
```yaml
|
||||
header: "Replicate Model"
|
||||
question: "Choose a default Replicate image generation model?"
|
||||
options:
|
||||
- label: "google/nano-banana-pro (Recommended)"
|
||||
description: "Google's fast image model on Replicate"
|
||||
- label: "google/nano-banana"
|
||||
description: "Google's base image model on Replicate"
|
||||
- label: "google/nano-banana-2 (Recommended)"
|
||||
description: "Current default for general Replicate image generation in baoyu-imagine"
|
||||
- label: "bytedance/seedream-4.5"
|
||||
description: "Replicate Seedream 4.5 with validated local size/ref guardrails"
|
||||
- label: "bytedance/seedream-5-lite"
|
||||
description: "Replicate Seedream 5 Lite with validated local size/ref guardrails"
|
||||
- label: "wan-video/wan-2.7-image-pro"
|
||||
description: "Replicate Wan 2.7 Image Pro with 4K text-to-image support"
|
||||
```
|
||||
|
||||
### MiniMax Model Selection
|
||||
@@ -302,6 +344,7 @@ default_model:
|
||||
azure: [value or null]
|
||||
openrouter: [value or null]
|
||||
dashscope: [value or null]
|
||||
zai: [value or null]
|
||||
minimax: [value or null]
|
||||
replicate: [value or null]
|
||||
```
|
||||
|
||||
@@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-imagine user preferences
|
||||
---
|
||||
version: 1
|
||||
|
||||
default_provider: null # google|openai|azure|openrouter|dashscope|minimax|replicate|null (null = auto-detect)
|
||||
default_provider: null # google|openai|azure|openrouter|dashscope|zai|minimax|replicate|null (null = auto-detect)
|
||||
|
||||
default_quality: null # normal|2k|null (null = use default: 2k)
|
||||
|
||||
@@ -19,14 +19,17 @@ default_aspect_ratio: null # "16:9"|"1:1"|"4:3"|"3:4"|"2.35:1"|null
|
||||
|
||||
default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides quality)
|
||||
|
||||
default_image_api_dialect: null # openai-native|ratio-metadata|null (OpenAI-compatible gateways; null = use env/default)
|
||||
|
||||
default_model:
|
||||
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
||||
azure: null # Azure deployment name, e.g., "gpt-image-1.5" or "image-prod"
|
||||
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
|
||||
dashscope: null # e.g., "qwen-image-2.0-pro"
|
||||
zai: null # e.g., "glm-image"
|
||||
minimax: null # e.g., "image-01"
|
||||
replicate: null # e.g., "google/nano-banana-pro"
|
||||
replicate: null # e.g., "google/nano-banana-2"
|
||||
|
||||
batch:
|
||||
max_workers: 10
|
||||
@@ -49,6 +52,9 @@ batch:
|
||||
dashscope:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
zai:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
minimax:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
@@ -64,11 +70,13 @@ batch:
|
||||
| `default_quality` | string\|null | null | Default quality (null = 2k) |
|
||||
| `default_aspect_ratio` | string\|null | null | Default aspect ratio |
|
||||
| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) |
|
||||
| `default_image_api_dialect` | string\|null | null | OpenAI-compatible image dialect (`openai-native` or `ratio-metadata`) |
|
||||
| `default_model.google` | string\|null | null | Google default model |
|
||||
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||
| `default_model.azure` | string\|null | null | Azure default deployment name |
|
||||
| `default_model.openrouter` | string\|null | null | OpenRouter default model |
|
||||
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||
| `default_model.zai` | string\|null | null | Z.AI default model |
|
||||
| `default_model.minimax` | string\|null | null | MiniMax default model |
|
||||
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
||||
@@ -83,6 +91,7 @@ batch:
|
||||
version: 1
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
default_image_api_dialect: null
|
||||
---
|
||||
```
|
||||
|
||||
@@ -94,14 +103,16 @@ default_provider: google
|
||||
default_quality: 2k
|
||||
default_aspect_ratio: "16:9"
|
||||
default_image_size: 2K
|
||||
default_image_api_dialect: null
|
||||
default_model:
|
||||
google: "gemini-3-pro-image-preview"
|
||||
openai: "gpt-image-1.5"
|
||||
azure: "gpt-image-1.5"
|
||||
openrouter: "google/gemini-3.1-flash-image-preview"
|
||||
dashscope: "qwen-image-2.0-pro"
|
||||
zai: "glm-image"
|
||||
minimax: "image-01"
|
||||
replicate: "google/nano-banana-pro"
|
||||
replicate: "google/nano-banana-2"
|
||||
batch:
|
||||
max_workers: 10
|
||||
provider_limits:
|
||||
@@ -111,6 +122,9 @@ batch:
|
||||
azure:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
zai:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
openrouter:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mergeConfig,
|
||||
normalizeOutputImagePath,
|
||||
parseArgs,
|
||||
parseOpenAIImageApiDialect,
|
||||
parseSimpleYaml,
|
||||
} from "./main.ts";
|
||||
|
||||
@@ -28,9 +29,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -78,11 +82,13 @@ test("parseArgs parses the main baoyu-imagine CLI flags", () => {
|
||||
"--image",
|
||||
"out/hero",
|
||||
"--provider",
|
||||
"openai",
|
||||
"zai",
|
||||
"--quality",
|
||||
"2k",
|
||||
"--imageSize",
|
||||
"4k",
|
||||
"--imageApiDialect",
|
||||
"ratio-metadata",
|
||||
"--ref",
|
||||
"ref/one.png",
|
||||
"ref/two.jpg",
|
||||
@@ -95,9 +101,12 @@ test("parseArgs parses the main baoyu-imagine CLI flags", () => {
|
||||
|
||||
assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]);
|
||||
assert.equal(args.imagePath, "out/hero");
|
||||
assert.equal(args.provider, "openai");
|
||||
assert.equal(args.provider, "zai");
|
||||
assert.equal(args.quality, "2k");
|
||||
assert.equal(args.aspectRatioSource, null);
|
||||
assert.equal(args.imageSize, "4K");
|
||||
assert.equal(args.imageSizeSource, "cli");
|
||||
assert.equal(args.imageApiDialect, "ratio-metadata");
|
||||
assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]);
|
||||
assert.equal(args.n, 3);
|
||||
assert.equal(args.jobs, 5);
|
||||
@@ -121,9 +130,11 @@ default_provider: openrouter
|
||||
default_quality: normal
|
||||
default_aspect_ratio: '16:9'
|
||||
default_image_size: 2K
|
||||
default_image_api_dialect: ratio-metadata
|
||||
default_model:
|
||||
google: gemini-3-pro-image-preview
|
||||
openai: gpt-image-1.5
|
||||
zai: glm-image
|
||||
azure: image-prod
|
||||
minimax: image-01
|
||||
batch:
|
||||
@@ -134,6 +145,9 @@ batch:
|
||||
start_interval_ms: 900
|
||||
openai:
|
||||
concurrency: 4
|
||||
zai:
|
||||
concurrency: 2
|
||||
start_interval_ms: 1000
|
||||
minimax:
|
||||
concurrency: 2
|
||||
start_interval_ms: 1400
|
||||
@@ -149,8 +163,10 @@ batch:
|
||||
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_image_api_dialect, "ratio-metadata");
|
||||
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
|
||||
assert.equal(config.default_model?.openai, "gpt-image-1.5");
|
||||
assert.equal(config.default_model?.zai, "glm-image");
|
||||
assert.equal(config.default_model?.azure, "image-prod");
|
||||
assert.equal(config.default_model?.minimax, "image-01");
|
||||
assert.equal(config.batch?.max_workers, 8);
|
||||
@@ -161,6 +177,10 @@ batch:
|
||||
assert.deepEqual(config.batch?.provider_limits?.openai, {
|
||||
concurrency: 4,
|
||||
});
|
||||
assert.deepEqual(config.batch?.provider_limits?.zai, {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 1000,
|
||||
});
|
||||
assert.deepEqual(config.batch?.provider_limits?.minimax, {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 1400,
|
||||
@@ -239,13 +259,48 @@ test("mergeConfig only fills values missing from CLI args", () => {
|
||||
default_quality: "2k",
|
||||
default_aspect_ratio: "3:2",
|
||||
default_image_size: "2K",
|
||||
default_image_api_dialect: "ratio-metadata",
|
||||
} satisfies Partial<ExtendConfig>,
|
||||
);
|
||||
|
||||
assert.equal(merged.provider, "openai");
|
||||
assert.equal(merged.quality, "2k");
|
||||
assert.equal(merged.aspectRatio, "3:2");
|
||||
assert.equal(merged.aspectRatioSource, "config");
|
||||
assert.equal(merged.imageSize, "4K");
|
||||
assert.equal(merged.imageSizeSource, "cli");
|
||||
assert.equal(merged.imageApiDialect, "ratio-metadata");
|
||||
});
|
||||
|
||||
test("mergeConfig tags inherited imageSize defaults so providers can ignore incompatible config", () => {
|
||||
const merged = mergeConfig(
|
||||
makeArgs(),
|
||||
{
|
||||
default_image_size: "2K",
|
||||
} satisfies Partial<ExtendConfig>,
|
||||
);
|
||||
|
||||
assert.equal(merged.imageSize, "2K");
|
||||
assert.equal(merged.imageSizeSource, "config");
|
||||
});
|
||||
|
||||
test("mergeConfig falls back to OPENAI_IMAGE_API_DIALECT when CLI and EXTEND are unset", (t) => {
|
||||
useEnv(t, {
|
||||
OPENAI_IMAGE_API_DIALECT: "ratio-metadata",
|
||||
});
|
||||
|
||||
const merged = mergeConfig(makeArgs(), {});
|
||||
assert.equal(merged.imageApiDialect, "ratio-metadata");
|
||||
});
|
||||
|
||||
test("parseOpenAIImageApiDialect validates supported values", () => {
|
||||
assert.equal(parseOpenAIImageApiDialect("openai-native"), "openai-native");
|
||||
assert.equal(parseOpenAIImageApiDialect("ratio-metadata"), "ratio-metadata");
|
||||
assert.equal(parseOpenAIImageApiDialect(null), null);
|
||||
assert.throws(
|
||||
() => parseOpenAIImageApiDialect("gateway-magic"),
|
||||
/Invalid OpenAI image API dialect/,
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => {
|
||||
@@ -316,6 +371,27 @@ test("detectProvider selects Azure when only Azure credentials are configured",
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider selects Z.AI when credentials are present or the model id matches", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_BASE_URL: null,
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
ZAI_API_KEY: "zai-key",
|
||||
BIGMODEL_API_KEY: null,
|
||||
MINIMAX_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()), "zai");
|
||||
assert.equal(detectProvider(makeArgs({ model: "glm-image" })), "zai");
|
||||
});
|
||||
|
||||
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
@@ -375,6 +451,7 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
||||
BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5",
|
||||
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
|
||||
BAOYU_IMAGE_GEN_ZAI_CONCURRENCY: "4",
|
||||
});
|
||||
|
||||
const extendConfig: Partial<ExtendConfig> = {
|
||||
@@ -385,6 +462,10 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
concurrency: 2,
|
||||
start_interval_ms: 900,
|
||||
},
|
||||
zai: {
|
||||
concurrency: 1,
|
||||
start_interval_ms: 1200,
|
||||
},
|
||||
minimax: {
|
||||
concurrency: 1,
|
||||
start_interval_ms: 1500,
|
||||
@@ -398,6 +479,10 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
concurrency: 5,
|
||||
startIntervalMs: 450,
|
||||
});
|
||||
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).zai, {
|
||||
concurrency: 4,
|
||||
startIntervalMs: 1200,
|
||||
});
|
||||
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, {
|
||||
concurrency: 1,
|
||||
startIntervalMs: 1500,
|
||||
@@ -435,6 +520,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
makeArgs({
|
||||
provider: "replicate",
|
||||
quality: "2k",
|
||||
imageApiDialect: "ratio-metadata",
|
||||
json: true,
|
||||
}),
|
||||
loaded.tasks[0]!,
|
||||
@@ -451,6 +537,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
assert.equal(taskArgs.provider, "replicate");
|
||||
assert.equal(taskArgs.aspectRatio, "16:9");
|
||||
assert.equal(taskArgs.quality, "2k");
|
||||
assert.equal(taskArgs.imageApiDialect, "ratio-metadata");
|
||||
assert.equal(taskArgs.json, true);
|
||||
});
|
||||
|
||||
@@ -464,5 +551,11 @@ test("path normalization, worker count, and retry classification follow expected
|
||||
assert.equal(getWorkerCount(5, 0, 4), 1);
|
||||
|
||||
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
|
||||
assert.equal(
|
||||
isRetryableGenerationError(
|
||||
new Error("Replicate returned 2 outputs, but baoyu-imagine currently supports saving exactly one image per request."),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isRetryableGenerationError(new Error("socket hang up")), true);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
BatchTaskInput,
|
||||
CliArgs,
|
||||
ExtendConfig,
|
||||
OpenAIImageApiDialect,
|
||||
Provider,
|
||||
} from "./types";
|
||||
|
||||
@@ -58,6 +59,7 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
||||
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
openrouter: { concurrency: 3, startIntervalMs: 1100 },
|
||||
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||
zai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
minimax: { concurrency: 3, startIntervalMs: 1100 },
|
||||
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
||||
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
||||
@@ -76,14 +78,15 @@ 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|minimax|replicate|jimeng|seedream|azure Force provider (auto-detect by default)
|
||||
--provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure 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)
|
||||
--quality normal|2k Quality preset (default: 2k)
|
||||
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
|
||||
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
--n <count> Number of images for the current task (default: 1)
|
||||
--imageApiDialect <id> OpenAI-compatible image dialect: openai-native|ratio-metadata
|
||||
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
--n <count> Number of images for the current task (default: 1; Replicate currently requires 1)
|
||||
--json JSON output
|
||||
-h, --help Show help
|
||||
|
||||
@@ -96,7 +99,7 @@ Batch file format:
|
||||
"promptFiles": ["prompts/hero.md"],
|
||||
"image": "out/hero.png",
|
||||
"provider": "replicate",
|
||||
"model": "google/nano-banana-pro",
|
||||
"model": "google/nano-banana-2",
|
||||
"ar": "16:9"
|
||||
}
|
||||
]
|
||||
@@ -106,6 +109,7 @@ Behavior:
|
||||
- Batch mode automatically runs in parallel when pending tasks >= 2
|
||||
- Each image retries automatically up to 3 attempts
|
||||
- Batch summary reports success count, failure count, and per-image errors
|
||||
- Replicate currently supports single-image save semantics only; --n must stay at 1
|
||||
|
||||
Environment variables:
|
||||
OPENAI_API_KEY OpenAI API key
|
||||
@@ -113,6 +117,8 @@ Environment variables:
|
||||
GOOGLE_API_KEY Google API key
|
||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope API key
|
||||
ZAI_API_KEY Z.AI API key
|
||||
BIGMODEL_API_KEY Backward-compatible alias for Z.AI API key
|
||||
MINIMAX_API_KEY MiniMax API key
|
||||
REPLICATE_API_TOKEN Replicate API token
|
||||
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
|
||||
@@ -122,17 +128,22 @@ Environment variables:
|
||||
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 (qwen-image-2.0-pro)
|
||||
ZAI_IMAGE_MODEL Default Z.AI model (glm-image)
|
||||
BIGMODEL_IMAGE_MODEL Backward-compatible alias for Z.AI model (glm-image)
|
||||
MINIMAX_IMAGE_MODEL Default MiniMax model (image-01)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-2)
|
||||
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_API_DIALECT OpenAI-compatible image dialect (openai-native|ratio-metadata)
|
||||
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
|
||||
OPENROUTER_BASE_URL Custom OpenRouter endpoint
|
||||
OPENROUTER_HTTP_REFERER Optional app URL for OpenRouter attribution
|
||||
OPENROUTER_TITLE Optional app name for OpenRouter attribution
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
ZAI_BASE_URL Custom Z.AI endpoint
|
||||
BIGMODEL_BASE_URL Backward-compatible alias for Z.AI endpoint
|
||||
MINIMAX_BASE_URL Custom MiniMax endpoint
|
||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||
JIMENG_BASE_URL Custom Jimeng endpoint
|
||||
@@ -157,9 +168,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -239,6 +253,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
v !== "openai" &&
|
||||
v !== "openrouter" &&
|
||||
v !== "dashscope" &&
|
||||
v !== "zai" &&
|
||||
v !== "minimax" &&
|
||||
v !== "replicate" &&
|
||||
v !== "jimeng" &&
|
||||
@@ -262,6 +277,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error("Missing value for --ar");
|
||||
out.aspectRatio = v;
|
||||
out.aspectRatioSource = "cli";
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -283,6 +299,16 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
const v = argv[++i]?.toUpperCase();
|
||||
if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`Invalid imageSize: ${v}`);
|
||||
out.imageSize = v;
|
||||
out.imageSizeSource = "cli";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--imageApiDialect") {
|
||||
const v = argv[++i];
|
||||
if (v !== "openai-native" && v !== "ratio-metadata") {
|
||||
throw new Error(`Invalid imageApiDialect: ${v}`);
|
||||
}
|
||||
out.imageApiDialect = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -389,12 +415,16 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
||||
} else if (key === "default_image_size") {
|
||||
config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K";
|
||||
} else if (key === "default_image_api_dialect") {
|
||||
config.default_image_api_dialect =
|
||||
value === "null" ? null : parseOpenAIImageApiDialect(value);
|
||||
} else if (key === "default_model") {
|
||||
config.default_model = {
|
||||
google: null,
|
||||
openai: null,
|
||||
openrouter: null,
|
||||
dashscope: null,
|
||||
zai: null,
|
||||
minimax: null,
|
||||
replicate: null,
|
||||
jimeng: null,
|
||||
@@ -423,6 +453,7 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "zai" ||
|
||||
key === "minimax" ||
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
@@ -441,6 +472,7 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "zai" ||
|
||||
key === "minimax" ||
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
@@ -471,6 +503,15 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
return config;
|
||||
}
|
||||
|
||||
export function parseOpenAIImageApiDialect(
|
||||
value: string | undefined | null
|
||||
): OpenAIImageApiDialect | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.replace(/['"]/g, "").trim();
|
||||
if (normalized === "openai-native" || normalized === "ratio-metadata") return normalized;
|
||||
throw new Error(`Invalid OpenAI image API dialect: ${value}`);
|
||||
}
|
||||
|
||||
type ExtendConfigPathPair = {
|
||||
current: string;
|
||||
legacy: string;
|
||||
@@ -530,12 +571,25 @@ export async function loadExtendConfig(
|
||||
}
|
||||
|
||||
export function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
const aspectRatio = args.aspectRatio ?? extend.default_aspect_ratio ?? null;
|
||||
const imageSize = args.imageSize ?? extend.default_image_size ?? null;
|
||||
const imageApiDialect =
|
||||
args.imageApiDialect ??
|
||||
extend.default_image_api_dialect ??
|
||||
parseOpenAIImageApiDialect(process.env.OPENAI_IMAGE_API_DIALECT);
|
||||
return {
|
||||
...args,
|
||||
provider: args.provider ?? extend.default_provider ?? null,
|
||||
quality: args.quality ?? extend.default_quality ?? null,
|
||||
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
|
||||
imageSize: args.imageSize ?? extend.default_image_size ?? null,
|
||||
aspectRatio,
|
||||
aspectRatioSource:
|
||||
args.aspectRatioSource ??
|
||||
(args.aspectRatio !== null ? "cli" : (aspectRatio !== null ? "config" : null)),
|
||||
imageSize,
|
||||
imageSizeSource:
|
||||
args.imageSizeSource ??
|
||||
(args.imageSize !== null ? "cli" : (imageSize !== null ? "config" : null)),
|
||||
imageApiDialect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -571,13 +625,14 @@ export function getConfiguredProviderRateLimits(
|
||||
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
|
||||
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||
zai: { ...DEFAULT_PROVIDER_RATE_LIMITS.zai },
|
||||
minimax: { ...DEFAULT_PROVIDER_RATE_LIMITS.minimax },
|
||||
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
||||
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
||||
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
|
||||
};
|
||||
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||
configured[provider] = {
|
||||
@@ -629,6 +684,7 @@ function inferProviderFromModel(model: string | null): Provider | null {
|
||||
const normalized = model.trim();
|
||||
if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream";
|
||||
if (normalized === "image-01" || normalized === "image-01-live") return "minimax";
|
||||
if (normalized === "glm-image" || normalized === "cogview-4-250304") return "zai";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -656,6 +712,7 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
const hasZai = !!(process.env.ZAI_API_KEY || process.env.BIGMODEL_API_KEY);
|
||||
const hasMinimax = !!process.env.MINIMAX_API_KEY;
|
||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
|
||||
@@ -676,6 +733,13 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
return "minimax";
|
||||
}
|
||||
|
||||
if (modelProvider === "zai") {
|
||||
if (!hasZai) {
|
||||
throw new Error("Model looks like a Z.AI image model, but ZAI_API_KEY is not set.");
|
||||
}
|
||||
return "zai";
|
||||
}
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
if (hasOpenai) return "openai";
|
||||
@@ -695,6 +759,7 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
hasAzure && "azure",
|
||||
hasOpenrouter && "openrouter",
|
||||
hasDashscope && "dashscope",
|
||||
hasZai && "zai",
|
||||
hasMinimax && "minimax",
|
||||
hasReplicate && "replicate",
|
||||
hasJimeng && "jimeng",
|
||||
@@ -705,7 +770,7 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
if (available.length > 1) return available[0]!;
|
||||
|
||||
throw new Error(
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
@@ -737,6 +802,7 @@ export function isRetryableGenerationError(error: unknown): boolean {
|
||||
"API error (403)",
|
||||
"API error (404)",
|
||||
"temporarily disabled",
|
||||
"supports saving exactly one image",
|
||||
];
|
||||
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
||||
}
|
||||
@@ -744,6 +810,7 @@ export function isRetryableGenerationError(error: unknown): boolean {
|
||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
|
||||
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
||||
if (provider === "zai") return (await import("./providers/zai")) as ProviderModule;
|
||||
if (provider === "minimax") return (await import("./providers/minimax")) as ProviderModule;
|
||||
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
||||
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
|
||||
@@ -775,6 +842,7 @@ function getModelForProvider(
|
||||
return extendConfig.default_model.openrouter;
|
||||
}
|
||||
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||
if (provider === "zai" && extendConfig.default_model.zai) return extendConfig.default_model.zai;
|
||||
if (provider === "minimax" && extendConfig.default_model.minimax) return extendConfig.default_model.minimax;
|
||||
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
||||
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
|
||||
@@ -848,9 +916,12 @@ export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir
|
||||
provider: task.provider ?? baseArgs.provider ?? null,
|
||||
model: task.model ?? baseArgs.model ?? null,
|
||||
aspectRatio: task.ar ?? baseArgs.aspectRatio ?? null,
|
||||
aspectRatioSource: task.ar != null ? "task" : (baseArgs.aspectRatioSource ?? null),
|
||||
size: task.size ?? baseArgs.size ?? null,
|
||||
quality: task.quality ?? baseArgs.quality ?? null,
|
||||
imageSize: task.imageSize ?? baseArgs.imageSize ?? null,
|
||||
imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null),
|
||||
imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null,
|
||||
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
|
||||
n: task.n ?? baseArgs.n,
|
||||
batchFile: null,
|
||||
@@ -999,7 +1070,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", "openrouter", "dashscope", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||
const limit = providerRateLimits[provider];
|
||||
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -2,9 +2,16 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildOpenAIGenerationsBody,
|
||||
extractImageFromResponse,
|
||||
getOpenAIAspectRatio,
|
||||
getOpenAIImageApiDialect,
|
||||
getOpenAIResolution,
|
||||
getMimeType,
|
||||
getOpenAISize,
|
||||
getOrientationFromAspectRatio,
|
||||
inferAspectRatioFromSize,
|
||||
inferResolutionFromSize,
|
||||
parseAspectRatio,
|
||||
} from "./openai.ts";
|
||||
|
||||
@@ -18,6 +25,69 @@ test("OpenAI aspect-ratio parsing and size selection match model families", () =
|
||||
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");
|
||||
assert.equal(inferAspectRatioFromSize("1536x1024"), "3:2");
|
||||
assert.equal(inferResolutionFromSize("1536x1024"), "2K");
|
||||
assert.equal(getOpenAIAspectRatio({ aspectRatio: null, size: "2048x1152" }), "16:9");
|
||||
assert.equal(getOpenAIResolution({ imageSize: null, size: "2048x1152", quality: "normal" }), "2K");
|
||||
assert.equal(getOrientationFromAspectRatio("16:9"), "landscape");
|
||||
assert.equal(getOrientationFromAspectRatio("9:16"), "portrait");
|
||||
assert.equal(getOrientationFromAspectRatio("1:1"), null);
|
||||
assert.equal(getOpenAIImageApiDialect({ imageApiDialect: null }), "openai-native");
|
||||
});
|
||||
|
||||
test("OpenAI generations body switches between native and ratio-metadata dialects", () => {
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a skyline", "gpt-image-1.5", {
|
||||
aspectRatio: "16:9",
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
}),
|
||||
{
|
||||
model: "gpt-image-1.5",
|
||||
prompt: "Draw a skyline",
|
||||
size: "1536x1024",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a skyline", "gemini-3-pro-image-preview", {
|
||||
aspectRatio: "16:9",
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: "ratio-metadata",
|
||||
}),
|
||||
{
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Draw a skyline",
|
||||
size: "16:9",
|
||||
metadata: {
|
||||
resolution: "2K",
|
||||
orientation: "landscape",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a portrait", "gemini-3-pro-image-preview", {
|
||||
aspectRatio: null,
|
||||
size: "1152x2048",
|
||||
quality: "normal",
|
||||
imageSize: null,
|
||||
imageApiDialect: "ratio-metadata",
|
||||
}),
|
||||
{
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Draw a portrait",
|
||||
size: "9:16",
|
||||
metadata: {
|
||||
resolution: "2K",
|
||||
orientation: "portrait",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI mime-type detection covers supported reference image extensions", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
import type { CliArgs, OpenAIImageApiDialect } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
@@ -23,6 +23,8 @@ type SizeMapping = {
|
||||
portrait: string;
|
||||
};
|
||||
|
||||
type OpenAIGenerationsBody = Record<string, unknown>;
|
||||
|
||||
export function getOpenAISize(
|
||||
model: string,
|
||||
ar: string | null,
|
||||
@@ -60,6 +62,114 @@ export function getOpenAISize(
|
||||
return sizes.square;
|
||||
}
|
||||
|
||||
function parsePixelSize(value: string): { width: number; height: number } | null {
|
||||
const match = value.match(/^(\d+)\s*[xX]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const width = parseInt(match[1]!, 10);
|
||||
const height = parseInt(match[2]!, 10);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
let x = Math.abs(a);
|
||||
let y = Math.abs(b);
|
||||
while (y !== 0) {
|
||||
const next = x % y;
|
||||
x = y;
|
||||
y = next;
|
||||
}
|
||||
return x || 1;
|
||||
}
|
||||
|
||||
export function getOpenAIImageApiDialect(args: Pick<CliArgs, "imageApiDialect">): OpenAIImageApiDialect {
|
||||
return args.imageApiDialect ?? "openai-native";
|
||||
}
|
||||
|
||||
export function inferAspectRatioFromSize(size: string | null): string | null {
|
||||
if (!size) return null;
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const divisor = gcd(parsed.width, parsed.height);
|
||||
return `${parsed.width / divisor}:${parsed.height / divisor}`;
|
||||
}
|
||||
|
||||
export function inferResolutionFromSize(size: string | null): "1K" | "2K" | "4K" | null {
|
||||
if (!size) return null;
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const longestEdge = Math.max(parsed.width, parsed.height);
|
||||
if (longestEdge <= 1024) return "1K";
|
||||
if (longestEdge <= 2048) return "2K";
|
||||
return "4K";
|
||||
}
|
||||
|
||||
export function getOpenAIAspectRatio(args: Pick<CliArgs, "aspectRatio" | "size">): string {
|
||||
return args.aspectRatio ?? inferAspectRatioFromSize(args.size) ?? "1:1";
|
||||
}
|
||||
|
||||
export function getOpenAIResolution(
|
||||
args: Pick<CliArgs, "imageSize" | "size" | "quality">
|
||||
): "1K" | "2K" | "4K" {
|
||||
if (args.imageSize === "1K" || args.imageSize === "2K" || args.imageSize === "4K") {
|
||||
return args.imageSize;
|
||||
}
|
||||
|
||||
const inferred = inferResolutionFromSize(args.size);
|
||||
if (inferred) return inferred;
|
||||
|
||||
return args.quality === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
export function getOrientationFromAspectRatio(ar: string): "landscape" | "portrait" | null {
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return null;
|
||||
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (Math.abs(ratio - 1) < 0.1) return null;
|
||||
return ratio > 1 ? "landscape" : "portrait";
|
||||
}
|
||||
|
||||
export function buildOpenAIGenerationsBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: Pick<CliArgs, "aspectRatio" | "size" | "quality" | "imageSize" | "imageApiDialect">
|
||||
): OpenAIGenerationsBody {
|
||||
if (getOpenAIImageApiDialect(args) === "ratio-metadata") {
|
||||
const aspectRatio = getOpenAIAspectRatio(args);
|
||||
const metadata: Record<string, string> = {
|
||||
resolution: getOpenAIResolution(args),
|
||||
};
|
||||
const orientation = getOrientationFromAspectRatio(aspectRatio);
|
||||
if (orientation) metadata.orientation = orientation;
|
||||
|
||||
return {
|
||||
model,
|
||||
prompt,
|
||||
size: aspectRatio,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const body: OpenAIGenerationsBody = {
|
||||
model,
|
||||
prompt,
|
||||
size: args.size || getOpenAISize(model, args.aspectRatio, args.quality),
|
||||
};
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = args.quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
@@ -78,18 +188,28 @@ export async function generateImage(
|
||||
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||
}
|
||||
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
const imageApiDialect = getOpenAIImageApiDialect(args);
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (imageApiDialect !== "openai-native") {
|
||||
throw new Error(
|
||||
"Reference images are not supported with the ratio-metadata OpenAI dialect yet. Use openai-native, Google, Azure, OpenRouter, MiniMax, Seedream, or Replicate for image-edit workflows."
|
||||
);
|
||||
}
|
||||
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||
throw new Error(
|
||||
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)."
|
||||
);
|
||||
}
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
|
||||
}
|
||||
|
||||
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
||||
return generateWithOpenAIGenerations(
|
||||
baseURL,
|
||||
apiKey,
|
||||
buildOpenAIGenerationsBody(prompt, model, args)
|
||||
);
|
||||
}
|
||||
|
||||
async function generateWithChatCompletions(
|
||||
@@ -129,17 +249,8 @@ async function generateWithChatCompletions(
|
||||
async function generateWithOpenAIGenerations(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
quality: CliArgs["quality"]
|
||||
body: OpenAIGenerationsBody
|
||||
): Promise<Uint8Array> {
|
||||
const body: Record<string, any> = { model, prompt, size };
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildInput,
|
||||
extractOutputUrl,
|
||||
getDefaultModel,
|
||||
getModelFamily,
|
||||
parseModelId,
|
||||
validateArgs,
|
||||
} from "./replicate.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
@@ -16,9 +19,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -29,10 +35,24 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
};
|
||||
}
|
||||
|
||||
test("Replicate model parsing accepts official formats and rejects malformed ones", () => {
|
||||
assert.deepEqual(parseModelId("google/nano-banana-pro"), {
|
||||
test("Replicate default model now points at nano-banana-2", () => {
|
||||
const previous = process.env.REPLICATE_IMAGE_MODEL;
|
||||
delete process.env.REPLICATE_IMAGE_MODEL;
|
||||
try {
|
||||
assert.equal(getDefaultModel(), "google/nano-banana-2");
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
delete process.env.REPLICATE_IMAGE_MODEL;
|
||||
} else {
|
||||
process.env.REPLICATE_IMAGE_MODEL = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Replicate model parsing and family detection accept supported official ids", () => {
|
||||
assert.deepEqual(parseModelId("google/nano-banana-2"), {
|
||||
owner: "google",
|
||||
name: "nano-banana-pro",
|
||||
name: "nano-banana-2",
|
||||
version: null,
|
||||
});
|
||||
assert.deepEqual(parseModelId("owner/model:abc123"), {
|
||||
@@ -41,46 +61,224 @@ test("Replicate model parsing accepts official formats and rejects malformed one
|
||||
version: "abc123",
|
||||
});
|
||||
|
||||
assert.equal(getModelFamily("google/nano-banana-pro"), "nano-banana");
|
||||
assert.equal(getModelFamily("bytedance/seedream-4.5"), "seedream45");
|
||||
assert.equal(getModelFamily("bytedance/seedream-5-lite"), "seedream5lite");
|
||||
assert.equal(getModelFamily("wan-video/wan-2.7-image"), "wan27image");
|
||||
assert.equal(getModelFamily("wan-video/wan-2.7-image-pro"), "wan27imagepro");
|
||||
assert.equal(getModelFamily("stability-ai/sdxl"), "unknown");
|
||||
|
||||
assert.throws(
|
||||
() => parseModelId("just-a-model-name"),
|
||||
/Invalid Replicate model format/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => {
|
||||
test("Replicate nano-banana input builder maps refs, aspect ratio, and quality presets", () => {
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"google/nano-banana-2",
|
||||
"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",
|
||||
aspect_ratio: "16:9",
|
||||
image_input: ["data:image/png;base64,AAAA"],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]),
|
||||
buildInput(
|
||||
"google/nano-banana-2",
|
||||
"A robot painter",
|
||||
makeArgs({ size: "1024x1024", quality: "normal" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "match_input_image",
|
||||
resolution: "1K",
|
||||
output_format: "png",
|
||||
image_input: ["ref"],
|
||||
aspect_ratio: "1:1",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate output extraction supports string, array, and object URLs", () => {
|
||||
test("Replicate Seedream and Wan inputs use family-specific request fields", () => {
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-4.5",
|
||||
"A cinematic portrait",
|
||||
makeArgs({ quality: "2k", referenceImages: ["local.png"] }),
|
||||
["data:image/png;base64,AAAA"],
|
||||
),
|
||||
{
|
||||
prompt: "A cinematic portrait",
|
||||
size: "4K",
|
||||
image_input: ["data:image/png;base64,AAAA"],
|
||||
aspect_ratio: "match_input_image",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-4.5",
|
||||
"A cinematic portrait",
|
||||
makeArgs({ size: "1536x1024" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A cinematic portrait",
|
||||
size: "custom",
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-5-lite",
|
||||
"A poster",
|
||||
makeArgs({ aspectRatio: "21:9", quality: "2k" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A poster",
|
||||
size: "3K",
|
||||
aspect_ratio: "21:9",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"wan-video/wan-2.7-image",
|
||||
"A storyboard frame",
|
||||
makeArgs({ aspectRatio: "16:9", quality: "2k" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A storyboard frame",
|
||||
size: "2048*1152",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"wan-video/wan-2.7-image-pro",
|
||||
"Blend these references",
|
||||
makeArgs({ size: "2K", referenceImages: ["a.png", "b.png"] }),
|
||||
["ref-a", "ref-b"],
|
||||
),
|
||||
{
|
||||
prompt: "Blend these references",
|
||||
size: "2K",
|
||||
images: ["ref-a", "ref-b"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate validateArgs blocks misleading multi-output and unsupported family options locally", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ n: 2 }),
|
||||
),
|
||||
/exactly one output image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"bytedance/seedream-4.5",
|
||||
makeArgs({ size: "1K" }),
|
||||
),
|
||||
/2K, 4K, or an explicit WxH size/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"bytedance/seedream-5-lite",
|
||||
makeArgs({ size: "4K" }),
|
||||
),
|
||||
/supports 2K or 3K output/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"wan-video/wan-2.7-image",
|
||||
makeArgs({ referenceImages: new Array(10).fill("ref.png") }),
|
||||
),
|
||||
/at most 9 reference images/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"wan-video/wan-2.7-image-pro",
|
||||
makeArgs({ referenceImages: ["ref.png"], size: "4K" }),
|
||||
),
|
||||
/only supports 4K text-to-image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9" }),
|
||||
),
|
||||
/compatibility list/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ imageSize: "2K", imageSizeSource: "config" }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ imageSize: "2K", imageSizeSource: "cli" }),
|
||||
),
|
||||
/do not use --imageSize/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9", aspectRatioSource: "config" }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9", aspectRatioSource: "cli" }),
|
||||
),
|
||||
/compatibility list/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate output extraction supports single outputs and rejects silent multi-image drops", () => {
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: "https://example.com/a.png" } as never),
|
||||
"https://example.com/a.png",
|
||||
@@ -94,6 +292,17 @@ test("Replicate output extraction supports string, array, and object URLs", () =
|
||||
"https://example.com/c.png",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
extractOutputUrl({
|
||||
output: [
|
||||
"https://example.com/one.png",
|
||||
"https://example.com/two.png",
|
||||
],
|
||||
} as never),
|
||||
/supports saving exactly one image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => extractOutputUrl({ output: { invalid: true } } as never),
|
||||
/Unexpected Replicate output format/,
|
||||
|
||||
@@ -2,10 +2,37 @@ import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const DEFAULT_MODEL = "google/nano-banana-pro";
|
||||
const DEFAULT_MODEL = "google/nano-banana-2";
|
||||
const SYNC_WAIT_SECONDS = 60;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_MS = 300_000;
|
||||
const DOCUMENTED_REPLICATE_ASPECT_RATIOS = new Set([
|
||||
"1:1",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"3:4",
|
||||
"4:3",
|
||||
"5:4",
|
||||
"4:5",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"21:9",
|
||||
]);
|
||||
|
||||
export type ReplicateModelFamily =
|
||||
| "nano-banana"
|
||||
| "seedream45"
|
||||
| "seedream5lite"
|
||||
| "wan27image"
|
||||
| "wan27imagepro"
|
||||
| "unknown";
|
||||
|
||||
type PixelSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Seedream45Size = "2K" | "4K" | { width: number; height: number };
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
@@ -20,6 +47,40 @@ function getBaseUrl(): string {
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeModelId(model: string): string {
|
||||
return model.trim().toLowerCase().split(":")[0]!;
|
||||
}
|
||||
|
||||
export function getModelFamily(model: string): ReplicateModelFamily {
|
||||
const normalized = normalizeModelId(model);
|
||||
|
||||
if (
|
||||
normalized === "google/nano-banana" ||
|
||||
normalized === "google/nano-banana-pro" ||
|
||||
normalized === "google/nano-banana-2"
|
||||
) {
|
||||
return "nano-banana";
|
||||
}
|
||||
|
||||
if (normalized === "bytedance/seedream-4.5") {
|
||||
return "seedream45";
|
||||
}
|
||||
|
||||
if (normalized === "bytedance/seedream-5-lite") {
|
||||
return "seedream5lite";
|
||||
}
|
||||
|
||||
if (normalized === "wan-video/wan-2.7-image") {
|
||||
return "wan27image";
|
||||
}
|
||||
|
||||
if (normalized === "wan-video/wan-2.7-image-pro") {
|
||||
return "wan27imagepro";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
const [ownerName, version] = model.split(":");
|
||||
const parts = ownerName!.split("/");
|
||||
@@ -31,27 +92,219 @@ export function parseModelId(model: string): { owner: string; name: string; vers
|
||||
return { owner: parts[0], name: parts[1], version: version || null };
|
||||
}
|
||||
|
||||
export function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = { prompt };
|
||||
function parsePixelSize(value: string): PixelSize | null {
|
||||
const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const width = parseInt(match[1]!, 10);
|
||||
const height = parseInt(match[2]!, 10);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function parseAspectRatio(value: string): PixelSize | null {
|
||||
const match = value.trim().match(/^(\d+)\s*:\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const width = parseInt(match[1]!, 10);
|
||||
const height = parseInt(match[2]!, 10);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
let x = Math.abs(a);
|
||||
let y = Math.abs(b);
|
||||
|
||||
while (y !== 0) {
|
||||
const next = x % y;
|
||||
x = y;
|
||||
y = next;
|
||||
}
|
||||
|
||||
return x || 1;
|
||||
}
|
||||
|
||||
function inferAspectRatioFromSize(size: string): string | null {
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const divisor = gcd(parsed.width, parsed.height);
|
||||
const normalized = `${parsed.width / divisor}:${parsed.height / divisor}`;
|
||||
if (!DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getQualityPreset(args: CliArgs): "normal" | "2k" {
|
||||
return args.quality === "normal" ? "normal" : "2k";
|
||||
}
|
||||
|
||||
function validateDocumentedAspectRatio(model: string, aspectRatio: string): void {
|
||||
if (aspectRatio === "match_input_image") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(aspectRatio)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Replicate model ${model} does not support aspect ratio ${aspectRatio}. Supported values: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
function getRequestedAspectRatio(model: string, args: CliArgs): string | null {
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
return args.aspectRatio;
|
||||
}
|
||||
|
||||
if (!args.size) return null;
|
||||
|
||||
const inferred = inferAspectRatioFromSize(args.size);
|
||||
if (!inferred) {
|
||||
throw new Error(
|
||||
`Replicate model ${model} cannot derive a supported aspect ratio from --size ${args.size}. Use one of: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function getNanoBananaResolution(args: CliArgs): "1K" | "2K" {
|
||||
if (args.size) {
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate nano-banana --size must be in WxH format, for example 1536x1024.");
|
||||
}
|
||||
|
||||
const longestEdge = Math.max(parsed.width, parsed.height);
|
||||
if (longestEdge <= 1024) return "1K";
|
||||
if (longestEdge <= 2048) return "2K";
|
||||
throw new Error("Replicate nano-banana only supports sizes that map to 1K or 2K output.");
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
function resolveSeedream45Size(args: CliArgs): Seedream45Size {
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "2K" || upper === "4K") {
|
||||
return upper;
|
||||
}
|
||||
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate Seedream 4.5 --size must be 2K, 4K, or an explicit WxH size.");
|
||||
}
|
||||
if (parsed.width < 1024 || parsed.width > 4096 || parsed.height < 1024 || parsed.height > 4096) {
|
||||
throw new Error("Replicate Seedream 4.5 custom --size must keep width and height between 1024 and 4096.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "2K" : "4K";
|
||||
}
|
||||
|
||||
function resolveSeedream5LiteSize(args: CliArgs): "2K" | "3K" {
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "2K" || upper === "3K") {
|
||||
return upper;
|
||||
}
|
||||
|
||||
throw new Error("Replicate Seedream 5 Lite currently supports 2K or 3K output in this tool.");
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "2K" : "3K";
|
||||
}
|
||||
|
||||
function formatCustomWanSize(size: PixelSize): string {
|
||||
return `${size.width}*${size.height}`;
|
||||
}
|
||||
|
||||
function resolveWanSizeFromAspectRatio(
|
||||
aspectRatio: string,
|
||||
maxDimension: number,
|
||||
): string {
|
||||
const parsedRatio = parseAspectRatio(aspectRatio);
|
||||
if (!parsedRatio) {
|
||||
throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${aspectRatio}.`);
|
||||
}
|
||||
|
||||
const scale = Math.min(maxDimension / parsedRatio.width, maxDimension / parsedRatio.height);
|
||||
const width = Math.max(1, Math.floor(parsedRatio.width * scale));
|
||||
const height = Math.max(1, Math.floor(parsedRatio.height * scale));
|
||||
return formatCustomWanSize({ width, height });
|
||||
}
|
||||
|
||||
function resolveWanSize(family: "wan27image" | "wan27imagepro", args: CliArgs): "1K" | "2K" | "4K" | string {
|
||||
const referenceMode = args.referenceImages.length > 0;
|
||||
const maxDimension = family === "wan27imagepro" && !referenceMode ? 4096 : 2048;
|
||||
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "1K" || upper === "2K" || upper === "4K") {
|
||||
if (upper === "4K" && family !== "wan27imagepro") {
|
||||
throw new Error("Replicate Wan 2.7 Image only supports 1K, 2K, or custom sizes up to 2048px.");
|
||||
}
|
||||
if (upper === "4K" && referenceMode) {
|
||||
throw new Error("Replicate Wan 2.7 Image Pro only supports 4K text-to-image. Remove --ref or lower the size.");
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate Wan --size must be 1K, 2K, 4K, or an explicit WxH size.");
|
||||
}
|
||||
if (parsed.width > maxDimension || parsed.height > maxDimension) {
|
||||
throw new Error(
|
||||
`Replicate ${family === "wan27imagepro" ? "Wan 2.7 Image Pro" : "Wan 2.7 Image"} custom --size must keep width and height at or below ${maxDimension}px in the current mode.`
|
||||
);
|
||||
}
|
||||
return formatCustomWanSize(parsed);
|
||||
}
|
||||
|
||||
if (args.aspectRatio) {
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
return resolveWanSizeFromAspectRatio(
|
||||
args.aspectRatio,
|
||||
getQualityPreset(args) === "normal" ? 1024 : 2048,
|
||||
);
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
function buildNanoBananaInput(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
resolution: getNanoBananaResolution(args),
|
||||
output_format: "png",
|
||||
};
|
||||
|
||||
const aspectRatio = getRequestedAspectRatio(model, args);
|
||||
if (aspectRatio) {
|
||||
input.aspect_ratio = aspectRatio;
|
||||
} else if (referenceImages.length > 0) {
|
||||
input.aspect_ratio = "match_input_image";
|
||||
}
|
||||
|
||||
if (args.n > 1) {
|
||||
input.number_of_images = args.n;
|
||||
}
|
||||
|
||||
if (args.quality === "normal") {
|
||||
input.resolution = "1K";
|
||||
} else if (args.quality === "2k") {
|
||||
input.resolution = "2K";
|
||||
}
|
||||
|
||||
input.output_format = "png";
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.image_input = referenceImages;
|
||||
}
|
||||
@@ -59,6 +312,158 @@ export function buildInput(prompt: string, args: CliArgs, referenceImages: strin
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildSeedreamInput(
|
||||
family: "seedream45" | "seedream5lite",
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const size = family === "seedream45" ? resolveSeedream45Size(args) : resolveSeedream5LiteSize(args);
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
};
|
||||
|
||||
if (family === "seedream45" && typeof size === "object") {
|
||||
input.size = "custom";
|
||||
input.width = size.width;
|
||||
input.height = size.height;
|
||||
} else {
|
||||
input.size = size;
|
||||
}
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.image_input = referenceImages;
|
||||
}
|
||||
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
} else if (referenceImages.length > 0 && family === "seedream45") {
|
||||
input.aspect_ratio = "match_input_image";
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildWanInput(
|
||||
family: "wan27image" | "wan27imagepro",
|
||||
prompt: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
size: resolveWanSize(family, args),
|
||||
};
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.images = referenceImages;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export function validateArgs(model: string, args: CliArgs): void {
|
||||
parseModelId(model);
|
||||
|
||||
if (args.n !== 1) {
|
||||
throw new Error("Replicate integration currently supports exactly one output image per request. Remove --n or use --n 1.");
|
||||
}
|
||||
|
||||
if (args.imageSize && args.imageSizeSource !== "config") {
|
||||
throw new Error("Replicate models in baoyu-imagine do not use --imageSize. Use --quality, --ar, or --size instead.");
|
||||
}
|
||||
|
||||
const family = getModelFamily(model);
|
||||
|
||||
if (family === "nano-banana") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate nano-banana supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
if (args.size) {
|
||||
getRequestedAspectRatio(model, args);
|
||||
getNanoBananaResolution(args);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "seedream45") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate Seedream 4.5 supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
resolveSeedream45Size(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "seedream5lite") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate Seedream 5 Lite supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
resolveSeedream5LiteSize(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "wan27image" || family === "wan27imagepro") {
|
||||
if (args.referenceImages.length > 9) {
|
||||
throw new Error("Replicate Wan 2.7 image models support at most 9 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
const parsed = parseAspectRatio(args.aspectRatio);
|
||||
if (!parsed) {
|
||||
throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${args.aspectRatio}.`);
|
||||
}
|
||||
}
|
||||
resolveWanSize(family, args);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExplicitAspectRatio = !!args.aspectRatio && args.aspectRatioSource !== "config";
|
||||
|
||||
if (args.referenceImages.length > 0 || hasExplicitAspectRatio || args.size) {
|
||||
throw new Error(
|
||||
`Replicate model ${model} is not in the baoyu-imagine compatibility list. Supported families: google/nano-banana*, bytedance/seedream-4.5, bytedance/seedream-5-lite, wan-video/wan-2.7-image, wan-video/wan-2.7-image-pro.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultOutputExtension(model: string): ".png" {
|
||||
const _family = getModelFamily(model);
|
||||
return ".png";
|
||||
}
|
||||
|
||||
export function buildInput(
|
||||
model: string,
|
||||
prompt: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const family = getModelFamily(model);
|
||||
|
||||
if (family === "nano-banana") {
|
||||
return buildNanoBananaInput(prompt, model, args, referenceImages);
|
||||
}
|
||||
|
||||
if (family === "seedream45" || family === "seedream5lite") {
|
||||
return buildSeedreamInput(family, prompt, model, args, referenceImages);
|
||||
}
|
||||
|
||||
if (family === "wan27image" || family === "wan27imagepro") {
|
||||
return buildWanInput(family, prompt, args, referenceImages);
|
||||
}
|
||||
|
||||
return { prompt };
|
||||
}
|
||||
|
||||
async function readImageAsDataUrl(p: string): Promise<string> {
|
||||
const buf = await readFile(p);
|
||||
const ext = path.extname(p).toLowerCase();
|
||||
@@ -150,6 +555,11 @@ export function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
if (typeof output === "string") return output;
|
||||
|
||||
if (Array.isArray(output)) {
|
||||
if (output.length !== 1) {
|
||||
throw new Error(
|
||||
`Replicate returned ${output.length} outputs, but baoyu-imagine currently supports saving exactly one image per request.`
|
||||
);
|
||||
}
|
||||
const first = output[0];
|
||||
if (typeof first === "string") return first;
|
||||
}
|
||||
@@ -178,13 +588,14 @@ export async function generateImage(
|
||||
if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens");
|
||||
|
||||
const parsedModel = parseModelId(model);
|
||||
validateArgs(model, args);
|
||||
|
||||
const refDataUrls: string[] = [];
|
||||
for (const refPath of args.referenceImages) {
|
||||
refDataUrls.push(await readImageAsDataUrl(refPath));
|
||||
}
|
||||
|
||||
const input = buildInput(prompt, args, refDataUrls);
|
||||
const input = buildInput(model, prompt, args, refDataUrls);
|
||||
|
||||
console.log(`Generating image with Replicate (${model})...`);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildRequestBody,
|
||||
buildZaiUrl,
|
||||
extractImageFromResponse,
|
||||
getDefaultModel,
|
||||
getModelFamily,
|
||||
parseAspectRatio,
|
||||
parseSize,
|
||||
resolveSizeForModel,
|
||||
validateArgs,
|
||||
} from "./zai.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
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("Z.AI default model prefers env override and otherwise uses glm-image", (t) => {
|
||||
useEnv(t, {
|
||||
ZAI_IMAGE_MODEL: null,
|
||||
BIGMODEL_IMAGE_MODEL: null,
|
||||
});
|
||||
assert.equal(getDefaultModel(), "glm-image");
|
||||
|
||||
process.env.BIGMODEL_IMAGE_MODEL = "cogview-4-250304";
|
||||
assert.equal(getDefaultModel(), "cogview-4-250304");
|
||||
});
|
||||
|
||||
test("Z.AI URL builder normalizes host, v4 base, and full endpoint inputs", (t) => {
|
||||
useEnv(t, { ZAI_BASE_URL: "https://api.z.ai" });
|
||||
assert.equal(buildZaiUrl(), "https://api.z.ai/api/paas/v4/images/generations");
|
||||
|
||||
process.env.ZAI_BASE_URL = "https://proxy.example.com/api/paas/v4/";
|
||||
assert.equal(buildZaiUrl(), "https://proxy.example.com/api/paas/v4/images/generations");
|
||||
|
||||
process.env.ZAI_BASE_URL = "https://proxy.example.com/custom/images/generations";
|
||||
assert.equal(buildZaiUrl(), "https://proxy.example.com/custom/images/generations");
|
||||
});
|
||||
|
||||
test("Z.AI model family and parsing helpers recognize documented formats", () => {
|
||||
assert.equal(getModelFamily("glm-image"), "glm");
|
||||
assert.equal(getModelFamily("cogview-4-250304"), "legacy");
|
||||
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
|
||||
assert.equal(parseAspectRatio("wide"), null);
|
||||
assert.deepEqual(parseSize("1280x1280"), { width: 1280, height: 1280 });
|
||||
assert.deepEqual(parseSize("1472*1088"), { width: 1472, height: 1088 });
|
||||
assert.equal(parseSize("big"), null);
|
||||
});
|
||||
|
||||
test("Z.AI size resolution follows documented recommended ratios and validates custom sizes", () => {
|
||||
assert.equal(
|
||||
resolveSizeForModel("glm-image", makeArgs({ aspectRatio: "16:9", quality: "2k" })),
|
||||
"1728x960",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSizeForModel("cogview-4-250304", makeArgs({ aspectRatio: "4:3", quality: "normal" })),
|
||||
"1152x864",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSizeForModel("glm-image", makeArgs({ size: "1568x1056", quality: "2k" })),
|
||||
"1568x1056",
|
||||
);
|
||||
|
||||
const uncommon = resolveSizeForModel(
|
||||
"glm-image",
|
||||
makeArgs({ aspectRatio: "5:2", quality: "normal" }),
|
||||
);
|
||||
const parsed = parseSize(uncommon);
|
||||
assert.ok(parsed);
|
||||
assert.ok(parsed.width % 32 === 0);
|
||||
assert.ok(parsed.height % 32 === 0);
|
||||
assert.ok(parsed.width * parsed.height <= 2 ** 22);
|
||||
|
||||
assert.throws(
|
||||
() => resolveSizeForModel("glm-image", makeArgs({ size: "1000x1000", quality: "2k" })),
|
||||
/between 1024 and 2048/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveSizeForModel("glm-image", makeArgs({ size: "1280x1260", quality: "2k" })),
|
||||
/divisible by 32/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveSizeForModel("cogview-4-250304", makeArgs({ size: "2048x2048", quality: "2k" })),
|
||||
/must not exceed 2\^21 total pixels/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Z.AI validation rejects unsupported refs and multi-image requests", () => {
|
||||
assert.throws(
|
||||
() => validateArgs("glm-image", makeArgs({ referenceImages: ["ref.png"] })),
|
||||
/text-to-image only/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs("glm-image", makeArgs({ n: 2 })),
|
||||
/single image per request/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Z.AI request body maps skill quality and resolved size into provider fields", () => {
|
||||
const body = buildRequestBody(
|
||||
"A cinematic science poster",
|
||||
"glm-image",
|
||||
makeArgs({ aspectRatio: "4:3", quality: "normal" }),
|
||||
);
|
||||
|
||||
assert.deepEqual(body, {
|
||||
model: "glm-image",
|
||||
prompt: "A cinematic science poster",
|
||||
quality: "standard",
|
||||
size: "1472x1088",
|
||||
});
|
||||
});
|
||||
|
||||
test("Z.AI response extraction downloads the returned image URL", async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(Uint8Array.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png" },
|
||||
});
|
||||
|
||||
const image = await extractImageFromResponse({
|
||||
data: [{ url: "https://cdn.example.com/glm-image.png" }],
|
||||
});
|
||||
assert.deepEqual([...image], [1, 2, 3]);
|
||||
|
||||
await assert.rejects(
|
||||
() => extractImageFromResponse({ data: [{}] }),
|
||||
/No image URL/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { CliArgs, Quality } from "../types";
|
||||
|
||||
type ZaiModelFamily = "glm" | "legacy";
|
||||
|
||||
type ZaiRequestBody = {
|
||||
model: string;
|
||||
prompt: string;
|
||||
quality: "hd" | "standard";
|
||||
size: string;
|
||||
};
|
||||
|
||||
type ZaiResponse = {
|
||||
data?: Array<{ url?: string }>;
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL = "glm-image";
|
||||
const GLM_MAX_PIXELS = 2 ** 22;
|
||||
const LEGACY_MAX_PIXELS = 2 ** 21;
|
||||
const GLM_SIZE_STEP = 32;
|
||||
const LEGACY_SIZE_STEP = 16;
|
||||
|
||||
const GLM_RECOMMENDED_SIZES: Record<string, string> = {
|
||||
"1:1": "1280x1280",
|
||||
"3:2": "1568x1056",
|
||||
"2:3": "1056x1568",
|
||||
"4:3": "1472x1088",
|
||||
"3:4": "1088x1472",
|
||||
"16:9": "1728x960",
|
||||
"9:16": "960x1728",
|
||||
};
|
||||
|
||||
const LEGACY_RECOMMENDED_SIZES: Record<string, string> = {
|
||||
"1:1": "1024x1024",
|
||||
"9:16": "768x1344",
|
||||
"3:4": "864x1152",
|
||||
"16:9": "1344x768",
|
||||
"4:3": "1152x864",
|
||||
"2:1": "1440x720",
|
||||
"1:2": "720x1440",
|
||||
};
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.ZAI_IMAGE_MODEL || process.env.BIGMODEL_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.ZAI_API_KEY || process.env.BIGMODEL_API_KEY || null;
|
||||
}
|
||||
|
||||
export function buildZaiUrl(): string {
|
||||
const base = (process.env.ZAI_BASE_URL || process.env.BIGMODEL_BASE_URL || "https://api.z.ai/api/paas/v4")
|
||||
.replace(/\/+$/g, "");
|
||||
if (base.endsWith("/images/generations")) return base;
|
||||
if (base.endsWith("/api/paas/v4")) return `${base}/images/generations`;
|
||||
if (base.endsWith("/v4")) return `${base}/images/generations`;
|
||||
return `${base}/api/paas/v4/images/generations`;
|
||||
}
|
||||
|
||||
export function getModelFamily(model: string): ZaiModelFamily {
|
||||
return model.trim().toLowerCase() === "glm-image" ? "glm" : "legacy";
|
||||
}
|
||||
|
||||
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function parseSize(size: string): { width: number; height: number } | null {
|
||||
const match = size.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
const width = parseInt(match[1]!, 10);
|
||||
const height = parseInt(match[2]!, 10);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function formatSize(width: number, height: number): string {
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
function roundToStep(value: number, step: number): number {
|
||||
return Math.max(step, Math.round(value / step) * step);
|
||||
}
|
||||
|
||||
function getRatioValue(ar: string): number | null {
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return null;
|
||||
return parsed.width / parsed.height;
|
||||
}
|
||||
|
||||
function findClosestRatioKey(ar: string, candidates: string[]): string | null {
|
||||
const targetRatio = getRatioValue(ar);
|
||||
if (targetRatio == null) return null;
|
||||
|
||||
let bestKey: string | null = null;
|
||||
let bestDiff = Infinity;
|
||||
for (const candidate of candidates) {
|
||||
const candidateRatio = getRatioValue(candidate);
|
||||
if (candidateRatio == null) continue;
|
||||
const diff = Math.abs(candidateRatio - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestKey = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestDiff <= 0.05 ? bestKey : null;
|
||||
}
|
||||
|
||||
function getTargetPixels(quality: Quality): number {
|
||||
return quality === "normal" ? 1024 * 1024 : 1536 * 1536;
|
||||
}
|
||||
|
||||
function fitToPixelBudget(
|
||||
width: number,
|
||||
height: number,
|
||||
targetPixels: number,
|
||||
maxPixels: number,
|
||||
step: number,
|
||||
): { width: number; height: number } {
|
||||
let nextWidth = width;
|
||||
let nextHeight = height;
|
||||
const pixels = nextWidth * nextHeight;
|
||||
|
||||
if (pixels > maxPixels) {
|
||||
const scale = Math.sqrt(maxPixels / pixels);
|
||||
nextWidth *= scale;
|
||||
nextHeight *= scale;
|
||||
} else {
|
||||
const scale = Math.sqrt(targetPixels / pixels);
|
||||
nextWidth *= scale;
|
||||
nextHeight *= scale;
|
||||
}
|
||||
|
||||
let roundedWidth = roundToStep(nextWidth, step);
|
||||
let roundedHeight = roundToStep(nextHeight, step);
|
||||
let roundedPixels = roundedWidth * roundedHeight;
|
||||
|
||||
while (roundedPixels > maxPixels && (roundedWidth > step || roundedHeight > step)) {
|
||||
if (roundedWidth >= roundedHeight && roundedWidth > step) {
|
||||
roundedWidth -= step;
|
||||
} else if (roundedHeight > step) {
|
||||
roundedHeight -= step;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
roundedPixels = roundedWidth * roundedHeight;
|
||||
}
|
||||
|
||||
return { width: roundedWidth, height: roundedHeight };
|
||||
}
|
||||
|
||||
function validateCustomSize(
|
||||
size: string,
|
||||
family: ZaiModelFamily,
|
||||
): string {
|
||||
const parsed = parseSize(size);
|
||||
if (!parsed) {
|
||||
throw new Error("Z.AI --size must be in WxH format, for example 1280x1280.");
|
||||
}
|
||||
|
||||
const widthStep = family === "glm" ? GLM_SIZE_STEP : LEGACY_SIZE_STEP;
|
||||
const minEdge = family === "glm" ? 1024 : 512;
|
||||
const maxPixels = family === "glm" ? GLM_MAX_PIXELS : LEGACY_MAX_PIXELS;
|
||||
|
||||
if (parsed.width < minEdge || parsed.width > 2048 || parsed.height < minEdge || parsed.height > 2048) {
|
||||
throw new Error(
|
||||
family === "glm"
|
||||
? "GLM-image custom size requires width and height between 1024 and 2048."
|
||||
: "Z.AI legacy image models require width and height between 512 and 2048."
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.width % widthStep !== 0 || parsed.height % widthStep !== 0) {
|
||||
throw new Error(
|
||||
family === "glm"
|
||||
? "GLM-image custom size requires width and height divisible by 32."
|
||||
: "Z.AI legacy image models require width and height divisible by 16."
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.width * parsed.height > maxPixels) {
|
||||
throw new Error(
|
||||
family === "glm"
|
||||
? "GLM-image custom size must not exceed 2^22 total pixels."
|
||||
: "Z.AI legacy image size must not exceed 2^21 total pixels."
|
||||
);
|
||||
}
|
||||
|
||||
return formatSize(parsed.width, parsed.height);
|
||||
}
|
||||
|
||||
export function resolveSizeForModel(
|
||||
model: string,
|
||||
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
|
||||
): string {
|
||||
const family = getModelFamily(model);
|
||||
const quality = args.quality === "normal" ? "normal" : "2k";
|
||||
|
||||
if (args.size) {
|
||||
return validateCustomSize(args.size, family);
|
||||
}
|
||||
|
||||
const recommended = family === "glm" ? GLM_RECOMMENDED_SIZES : LEGACY_RECOMMENDED_SIZES;
|
||||
const defaultSize = family === "glm" ? "1280x1280" : "1024x1024";
|
||||
|
||||
if (!args.aspectRatio) return defaultSize;
|
||||
|
||||
const recommendedRatio = findClosestRatioKey(args.aspectRatio, Object.keys(recommended));
|
||||
if (recommendedRatio) {
|
||||
return recommended[recommendedRatio]!;
|
||||
}
|
||||
|
||||
const parsedRatio = parseAspectRatio(args.aspectRatio);
|
||||
if (!parsedRatio) return defaultSize;
|
||||
|
||||
const targetPixels = getTargetPixels(quality);
|
||||
const maxPixels = family === "glm" ? GLM_MAX_PIXELS : LEGACY_MAX_PIXELS;
|
||||
const step = family === "glm" ? GLM_SIZE_STEP : LEGACY_SIZE_STEP;
|
||||
const fit = fitToPixelBudget(
|
||||
parsedRatio.width,
|
||||
parsedRatio.height,
|
||||
targetPixels,
|
||||
maxPixels,
|
||||
step,
|
||||
);
|
||||
return formatSize(fit.width, fit.height);
|
||||
}
|
||||
|
||||
function getZaiQuality(quality: CliArgs["quality"]): "hd" | "standard" {
|
||||
return quality === "normal" ? "standard" : "hd";
|
||||
}
|
||||
|
||||
export function validateArgs(_model: string, args: CliArgs): void {
|
||||
if (args.referenceImages.length > 0) {
|
||||
throw new Error("Z.AI GLM-image currently supports text-to-image only in baoyu-imagine. Remove --ref or choose another provider.");
|
||||
}
|
||||
|
||||
if (args.n > 1) {
|
||||
throw new Error("Z.AI image generation currently returns a single image per request in baoyu-imagine.");
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRequestBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
): ZaiRequestBody {
|
||||
validateArgs(model, args);
|
||||
return {
|
||||
model,
|
||||
prompt,
|
||||
quality: getZaiQuality(args.quality),
|
||||
size: resolveSizeForModel(model, args),
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractImageFromResponse(result: ZaiResponse): Promise<Uint8Array> {
|
||||
const url = result.data?.[0]?.url;
|
||||
if (!url) {
|
||||
throw new Error("No image URL in Z.AI response");
|
||||
}
|
||||
|
||||
const imageResponse = await fetch(url);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`Failed to download image from Z.AI: ${imageResponse.status}`);
|
||||
}
|
||||
|
||||
return new Uint8Array(await imageResponse.arrayBuffer());
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error("ZAI_API_KEY is required. Get one from https://docs.z.ai/.");
|
||||
}
|
||||
|
||||
const response = await fetch(buildZaiUrl(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(buildRequestBody(prompt, model, args)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Z.AI API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as ZaiResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
@@ -3,12 +3,14 @@ export type Provider =
|
||||
| "openai"
|
||||
| "openrouter"
|
||||
| "dashscope"
|
||||
| "zai"
|
||||
| "minimax"
|
||||
| "replicate"
|
||||
| "jimeng"
|
||||
| "seedream"
|
||||
| "azure";
|
||||
export type Quality = "normal" | "2k";
|
||||
export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata";
|
||||
|
||||
export type CliArgs = {
|
||||
prompt: string | null;
|
||||
@@ -17,9 +19,12 @@ export type CliArgs = {
|
||||
provider: Provider | null;
|
||||
model: string | null;
|
||||
aspectRatio: string | null;
|
||||
aspectRatioSource?: "cli" | "task" | "config" | null;
|
||||
size: string | null;
|
||||
quality: Quality | null;
|
||||
imageSize: string | null;
|
||||
imageSizeSource?: "cli" | "task" | "config" | null;
|
||||
imageApiDialect: OpenAIImageApiDialect | null;
|
||||
referenceImages: string[];
|
||||
n: number;
|
||||
batchFile: string | null;
|
||||
@@ -39,6 +44,7 @@ export type BatchTaskInput = {
|
||||
size?: string | null;
|
||||
quality?: Quality | null;
|
||||
imageSize?: "1K" | "2K" | "4K" | null;
|
||||
imageApiDialect?: OpenAIImageApiDialect | null;
|
||||
ref?: string[];
|
||||
n?: number;
|
||||
};
|
||||
@@ -56,11 +62,13 @@ export type ExtendConfig = {
|
||||
default_quality: Quality | null;
|
||||
default_aspect_ratio: string | null;
|
||||
default_image_size: "1K" | "2K" | "4K" | null;
|
||||
default_image_api_dialect: OpenAIImageApiDialect | null;
|
||||
default_model: {
|
||||
google: string | null;
|
||||
openai: string | null;
|
||||
openrouter: string | null;
|
||||
dashscope: string | null;
|
||||
zai: string | null;
|
||||
minimax: string | null;
|
||||
replicate: string | null;
|
||||
jimeng: string | null;
|
||||
|
||||
@@ -46,6 +46,45 @@ export function stripWrappingQuotes(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
function decodeHtmlCodePoint(codePoint: number, fallback: string): string {
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body: string) => {
|
||||
const normalized = body.toLowerCase();
|
||||
if (normalized.startsWith("#x")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(2), 16), entity);
|
||||
}
|
||||
if (normalized.startsWith("#")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(1), 10), entity);
|
||||
}
|
||||
return HTML_ENTITIES[normalized] ?? entity;
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanSummaryText(value: string): string {
|
||||
return decodeHtmlEntities(stripWrappingQuotes(value))
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?[a-z][a-z0-9:-]*(?:\s+[^>]*)?>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
@@ -94,10 +133,11 @@ export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
const summaryText = cleanSummaryText(cleanText);
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
if (summaryText.length > 20) {
|
||||
if (summaryText.length <= maxLen) return summaryText;
|
||||
return `${summaryText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,19 +45,24 @@ export function loadCodeThemeCss(themeName: string): string {
|
||||
}
|
||||
|
||||
export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const escapeHtmlAttribute = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${meta.title}</title>`,
|
||||
` <title>${escapeHtmlAttribute(meta.title)}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
lines.push(` <meta name="author" content="${escapeHtmlAttribute(meta.author)}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
lines.push(` <meta name="description" content="${escapeHtmlAttribute(meta.description)}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
cleanSummaryText,
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
parseFrontmatter,
|
||||
@@ -47,8 +48,9 @@ export async function convertMarkdown(
|
||||
}
|
||||
|
||||
const author = stripWrappingQuotes(frontmatter.author ?? "");
|
||||
let summary = stripWrappingQuotes(frontmatter.description ?? "")
|
||||
const frontmatterSummary = stripWrappingQuotes(frontmatter.description ?? "")
|
||||
|| stripWrappingQuotes(frontmatter.summary ?? "");
|
||||
let summary = cleanSummaryText(frontmatterSummary);
|
||||
if (!summary) {
|
||||
summary = extractSummaryFromBody(body, 120);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,45 @@ export function stripWrappingQuotes(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
function decodeHtmlCodePoint(codePoint: number, fallback: string): string {
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body: string) => {
|
||||
const normalized = body.toLowerCase();
|
||||
if (normalized.startsWith("#x")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(2), 16), entity);
|
||||
}
|
||||
if (normalized.startsWith("#")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(1), 10), entity);
|
||||
}
|
||||
return HTML_ENTITIES[normalized] ?? entity;
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanSummaryText(value: string): string {
|
||||
return decodeHtmlEntities(stripWrappingQuotes(value))
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?[a-z][a-z0-9:-]*(?:\s+[^>]*)?>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
@@ -94,10 +133,11 @@ export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
const summaryText = cleanSummaryText(cleanText);
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
if (summaryText.length > 20) {
|
||||
if (summaryText.length <= maxLen) return summaryText;
|
||||
return `${summaryText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,19 +45,24 @@ export function loadCodeThemeCss(themeName: string): string {
|
||||
}
|
||||
|
||||
export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const escapeHtmlAttribute = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${meta.title}</title>`,
|
||||
` <title>${escapeHtmlAttribute(meta.title)}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
lines.push(` <meta name="author" content="${escapeHtmlAttribute(meta.author)}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
lines.push(` <meta name="description" content="${escapeHtmlAttribute(meta.description)}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
|
||||
@@ -194,6 +194,54 @@ async function pasteFromClipboardInEditor(session: ChromeSession): Promise<void>
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
async function prepareEditorPasteTarget(
|
||||
session: ChromeSession,
|
||||
context: string,
|
||||
options: { clickEditor?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await session.cdp.send('Target.activateTarget', { targetId: session.targetId }).catch(() => {});
|
||||
await sleep(100);
|
||||
|
||||
if (options.clickEditor) {
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
const ready = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
|
||||
const active = document.activeElement;
|
||||
const selection = window.getSelection();
|
||||
const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);
|
||||
const focusInEditor = !!active && (active === editor || editor.contains(active));
|
||||
if (selectionInEditor || focusInEditor) return true;
|
||||
|
||||
if (${JSON.stringify(Boolean(options.clickEditor))}) {
|
||||
editor.focus();
|
||||
const nextActive = document.activeElement;
|
||||
return nextActive === editor || editor.contains(nextActive);
|
||||
}
|
||||
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (ready) return;
|
||||
|
||||
const activeElement = await evaluate<string>(session, `
|
||||
(function() {
|
||||
const el = document.activeElement;
|
||||
if (!el) return '(none)';
|
||||
const id = el.id ? '#' + el.id : '';
|
||||
const className = typeof el.className === 'string' && el.className ? '.' + el.className.split(/\\s+/).join('.') : '';
|
||||
return el.tagName.toLowerCase() + id + className;
|
||||
})()
|
||||
`);
|
||||
throw new Error(`Body editor is not focused before ${context}; active element: ${activeElement}`);
|
||||
}
|
||||
|
||||
async function parseMarkdownWithPlaceholders(
|
||||
markdownPath: string,
|
||||
theme?: string,
|
||||
@@ -567,6 +615,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
|
||||
await sleep(500);
|
||||
await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });
|
||||
console.log('[wechat] Pasting into editor...');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
@@ -608,6 +657,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await sleep(200);
|
||||
|
||||
console.log('[wechat] Pasting image...');
|
||||
await prepareEditorPasteTarget(session, 'inline image paste');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
@@ -620,6 +670,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log(`[wechat] Pasting image: ${img}`);
|
||||
await copyImageToClipboard(img);
|
||||
await sleep(500);
|
||||
await prepareEditorPasteTarget(session, 'leading image paste');
|
||||
await pasteInEditor(session);
|
||||
await sleep(2000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
@@ -627,6 +678,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
|
||||
console.log('[wechat] Typing content...');
|
||||
await prepareEditorPasteTarget(session, 'content typing');
|
||||
await typeText(session, content);
|
||||
await sleep(1000);
|
||||
|
||||
|
||||
@@ -46,6 +46,45 @@ export function stripWrappingQuotes(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
function decodeHtmlCodePoint(codePoint: number, fallback: string): string {
|
||||
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, body: string) => {
|
||||
const normalized = body.toLowerCase();
|
||||
if (normalized.startsWith("#x")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(2), 16), entity);
|
||||
}
|
||||
if (normalized.startsWith("#")) {
|
||||
return decodeHtmlCodePoint(Number.parseInt(normalized.slice(1), 10), entity);
|
||||
}
|
||||
return HTML_ENTITIES[normalized] ?? entity;
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanSummaryText(value: string): string {
|
||||
return decodeHtmlEntities(stripWrappingQuotes(value))
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?[a-z][a-z0-9:-]*(?:\s+[^>]*)?>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
@@ -94,10 +133,11 @@ export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
const summaryText = cleanSummaryText(cleanText);
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
if (summaryText.length > 20) {
|
||||
if (summaryText.length <= maxLen) return summaryText;
|
||||
return `${summaryText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,19 +45,24 @@ export function loadCodeThemeCss(themeName: string): string {
|
||||
}
|
||||
|
||||
export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
|
||||
const escapeHtmlAttribute = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${meta.title}</title>`,
|
||||
` <title>${escapeHtmlAttribute(meta.title)}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
lines.push(` <meta name="author" content="${escapeHtmlAttribute(meta.author)}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
lines.push(` <meta name="description" content="${escapeHtmlAttribute(meta.description)}" />`);
|
||||
}
|
||||
lines.push(` <style>${css}</style>`);
|
||||
if (codeThemeCss) {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
---
|
||||
name: baoyu-xhs-images
|
||||
description: "DEPRECATED: Migrated to baoyu-image-cards. Generates Xiaohongshu (Little Red Book) infographic series with 11 visual styles and 8 layouts. Breaks content into 1-10 cartoon-style images optimized for XHS engagement. Use when user mentions \"小红书图片\", \"XHS images\", \"RedNote infographics\", \"小红书种草\", or wants social media infographics for Chinese platforms."
|
||||
description: Generates Xiaohongshu (Little Red Book) image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for XHS engagement. Use when user mentions "小红书图片", "XHS images", "RedNote infographics", "小红书种草", "小绿书", "微信图文", "微信贴图", or wants social media infographic series for Chinese platforms.
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-xhs-images
|
||||
---
|
||||
|
||||
# Xiaohongshu Infographic Series Generator
|
||||
# Xiaohongshu Image Card Series Generator
|
||||
|
||||
> **⚠️ DEPRECATED**: This skill has been migrated to `baoyu-image-cards`. Print a bold red warning to the user **in the user's language** (use ANSI escape `\033[1;31m`) stating that `baoyu-xhs-images` has been migrated to `baoyu-image-cards` and they should use `baoyu-image-cards` instead. Then STOP — do NOT continue executing this skill.
|
||||
|
||||
Break down complex content into eye-catching infographic series for Xiaohongshu with multiple style options.
|
||||
Break down complex content into eye-catching Xiaohongshu image card series with multiple style options.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -28,12 +26,18 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
|
||||
# Combine style and layout
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion --layout list
|
||||
|
||||
# Use preset (style + layout shorthand)
|
||||
# Specify palette (override style colors)
|
||||
/baoyu-xhs-images posts/ai-future/article.md --style notion --palette macaron
|
||||
|
||||
# Use preset (style + layout + optional palette shorthand)
|
||||
/baoyu-xhs-images posts/ai-future/article.md --preset knowledge-card
|
||||
|
||||
# Preset with override
|
||||
/baoyu-xhs-images posts/ai-future/article.md --preset poster --layout quadrant
|
||||
|
||||
# Preset with palette override
|
||||
/baoyu-xhs-images posts/ai-future/article.md --preset hand-drawn-edu --palette warm
|
||||
|
||||
# Direct content input
|
||||
/baoyu-xhs-images
|
||||
[paste content]
|
||||
@@ -53,25 +57,33 @@ Break down complex content into eye-catching infographic series for Xiaohongshu
|
||||
|--------|-------------|
|
||||
| `--style <name>` | Visual style (see Style Gallery) |
|
||||
| `--layout <name>` | Information layout (see Layout Gallery) |
|
||||
| `--preset <name>` | Style + layout shorthand (see [Style Presets](references/style-presets.md)) |
|
||||
| `--palette <name>` | Color palette override (see Palette Gallery) |
|
||||
| `--preset <name>` | Style + layout + optional palette shorthand (see [Style Presets](references/style-presets.md)) |
|
||||
| `--yes` | Non-interactive mode: skip all confirmations. Uses EXTEND.md preferences if found, otherwise uses defaults (no watermark, auto style/layout). Auto-confirms recommended plan (Path A). Suitable for scheduled tasks and automation. |
|
||||
|
||||
## Two Dimensions
|
||||
## Dimensions
|
||||
|
||||
| Dimension | Controls | Options |
|
||||
|-----------|----------|---------|
|
||||
| **Style** | Visual aesthetics: colors, lines, decorations | cute, fresh, warm, bold, minimal, retro, pop, notion, chalkboard, study-notes, screen-print |
|
||||
| **Style** | Visual aesthetics: lines, decorations, rendering | cute, fresh, warm, bold, minimal, retro, pop, notion, chalkboard, study-notes, screen-print, sketch-notes |
|
||||
| **Layout** | Information structure: density, arrangement | sparse, balanced, dense, list, comparison, flow, mindmap, quadrant |
|
||||
| **Palette** (optional) | Color override: replaces style's default colors | macaron, warm, neon |
|
||||
|
||||
Style × Layout can be freely combined. Example: `--style notion --layout dense` creates an intellectual-looking knowledge card with high information density.
|
||||
Style × Layout can be freely combined, with optional palette override. Example: `--style notion --layout dense` creates an intellectual-looking knowledge card with high information density. Add `--palette macaron` to swap colors to soft pastels while keeping notion's rendering style.
|
||||
|
||||
Or use presets: `--preset knowledge-card` → style + layout in one flag. See [Style Presets](references/style-presets.md).
|
||||
|
||||
**Palette behavior**:
|
||||
- No `--palette` → style uses its built-in colors (or its `default_palette` if defined)
|
||||
- `--palette macaron` → overrides any style's colors with macaron palette
|
||||
- Palette replaces colors only; style rendering rules (line treatment, elements, textures) stay unchanged
|
||||
- Some styles declare a `default_palette` (e.g., sketch-notes defaults to macaron)
|
||||
|
||||
## Style Gallery
|
||||
|
||||
| Style | Description |
|
||||
|-------|-------------|
|
||||
| `cute` (Default) | Sweet, adorable, girly - classic Xiaohongshu aesthetic |
|
||||
| `cute` (Default) | Sweet, adorable, girly aesthetic |
|
||||
| `fresh` | Clean, refreshing, natural |
|
||||
| `warm` | Cozy, friendly, approachable |
|
||||
| `bold` | High impact, attention-grabbing |
|
||||
@@ -82,6 +94,7 @@ Or use presets: `--preset knowledge-card` → style + layout in one flag. See [S
|
||||
| `chalkboard` | Colorful chalk on black board, educational |
|
||||
| `study-notes` | Realistic handwritten photo style, blue pen + red annotations + yellow highlighter |
|
||||
| `screen-print` | Bold poster art, halftone textures, limited colors, symbolic storytelling |
|
||||
| `sketch-notes` | Hand-drawn educational infographic, macaron pastels on warm cream, wobble lines |
|
||||
|
||||
Detailed style definitions: `references/presets/<style>.md`
|
||||
|
||||
@@ -100,6 +113,9 @@ Quick-start presets by content scenario. Use `--preset <name>` or recommend duri
|
||||
| `tutorial` | chalkboard | flow | 教程步骤、操作流程 |
|
||||
| `classroom` | chalkboard | balanced | 课堂笔记、知识讲解 |
|
||||
| `study-guide` | study-notes | dense | 学习笔记、考试重点 |
|
||||
| `hand-drawn-edu` | sketch-notes | flow | 手绘教程、流程图解 |
|
||||
| `sketch-card` | sketch-notes | dense | 手绘知识卡、概念科普 |
|
||||
| `sketch-summary` | sketch-notes | balanced | 手绘总结、图文笔记 |
|
||||
|
||||
**Lifestyle & Sharing**:
|
||||
|
||||
@@ -154,6 +170,18 @@ Full preset definitions: [references/style-presets.md](references/style-presets.
|
||||
|
||||
Detailed layout definitions: `references/elements/canvas.md`
|
||||
|
||||
## Palette Gallery
|
||||
|
||||
Optional color override. Replaces style's built-in colors while preserving rendering rules.
|
||||
|
||||
| Palette | Background | Zone Colors | Accent | Feel |
|
||||
|---------|------------|-------------|--------|------|
|
||||
| `macaron` | Warm cream #F5F0E8 | Blue #A8D8EA, Lavender #D5C6E0, Mint #B5E5CF, Peach #F8D5C4 | Coral #E8655A | Soft, educational, approachable |
|
||||
| `warm` | Soft Peach #FFECD2 | Orange #ED8936, Terracotta #C05621, Golden #F6AD55, Rose #D4A09A | Sienna #A0522D | Cozy, earth tones, no cool colors |
|
||||
| `neon` | Dark Purple #1A1025 | Cyan #00F5FF, Magenta #FF00FF, Green #39FF14, Pink #FF6EC7 | Yellow #FFFF00 | High-energy, futuristic |
|
||||
|
||||
Detailed palette definitions: `references/palettes/<palette>.md`
|
||||
|
||||
## Auto Selection
|
||||
|
||||
| Content Signals | Style | Layout | Recommended Preset |
|
||||
@@ -169,6 +197,7 @@ Detailed layout definitions: `references/elements/canvas.md`
|
||||
| Education, tutorial, learning, teaching, classroom | `chalkboard` | balanced/dense | `tutorial`, `classroom` |
|
||||
| Notes, handwritten, study guide, knowledge, realistic, photo | `study-notes` | dense/list/mindmap | `study-guide` |
|
||||
| Movie, album, concert, poster, opinion, editorial, dramatic, cinematic | `screen-print` | sparse/comparison | `poster`, `editorial`, `cinematic` |
|
||||
| Hand-drawn, infographic, diagram, visual summary, 手绘, 图解, workflow, process | `sketch-notes` | flow/balanced/dense | `hand-drawn-edu`, `sketch-card`, `sketch-summary` |
|
||||
|
||||
## Outline Strategies
|
||||
|
||||
@@ -346,21 +375,21 @@ Read source content, save it if needed, and perform deep analysis.
|
||||
- Swipe flow design
|
||||
4. Detect source language
|
||||
5. Determine recommended image count (2-10)
|
||||
6. **Auto-recommend** best strategy + style + layout based on content signals
|
||||
6. **Auto-recommend** best strategy + style + layout + palette based on content signals
|
||||
7. **Save to `analysis.md`**
|
||||
|
||||
### Step 2: Smart Confirm ⚠️
|
||||
|
||||
**Purpose**: Present auto-recommended plan, let user confirm or adjust.
|
||||
|
||||
**`--yes` mode**: Skip this entire step. Use auto-recommended strategy + style + layout from Step 1 analysis (or `--style`/`--layout`/`--preset` if provided). Generate outline directly using Path A logic → save to `outline.md` → proceed to Step 3. No AskUserQuestion calls.
|
||||
**`--yes` mode**: Skip this entire step. Use auto-recommended strategy + style + layout + palette from Step 1 analysis (or `--style`/`--layout`/`--palette`/`--preset` if provided). Generate outline directly using Path A logic → save to `outline.md` → proceed to Step 3. No AskUserQuestion calls.
|
||||
|
||||
**Interactive mode**: Do NOT skip.
|
||||
|
||||
**Auto-Recommendation Logic**:
|
||||
1. Use Auto Selection table to match content signals → best strategy + style + layout
|
||||
1. Use Auto Selection table to match content signals → best strategy + style + layout + palette
|
||||
2. Infer optimal image count from content density
|
||||
3. Load style's default elements from preset
|
||||
3. Load style's default elements from preset (apply palette override if applicable)
|
||||
|
||||
**Display** (analysis summary + recommended plan):
|
||||
|
||||
@@ -373,7 +402,7 @@ Read source content, save it if needed, and perform deep analysis.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🎨 推荐方案(自动匹配)
|
||||
策略:[A/B/C] [strategy name]([reason])
|
||||
风格:[style] · 布局:[layout] · 预设:[preset]
|
||||
风格:[style] · 布局:[layout] · 配色:[palette or "默认"] · 预设:[preset]
|
||||
图片:[N]张(封面+[N-2]内容+结尾)
|
||||
元素:[background] / [decorations] / [emphasis]
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -395,10 +424,11 @@ Generate single outline using recommended strategy + style → save to `outline.
|
||||
|
||||
**Use AskUserQuestion** with adjustable options (leave blank = keep recommended):
|
||||
|
||||
1. **策略风格**: Current: [strategy + style]. Options: A Story-Driven(warm) | B Information-Dense(notion) | C Visual-First(screen-print). Or specify style directly: cute/fresh/warm/bold/minimal/retro/pop/notion/chalkboard/study-notes/screen-print. Or use preset: knowledge-card / checklist / tutorial / poster / cinematic / etc.
|
||||
1. **策略风格**: Current: [strategy + style]. Options: A Story-Driven(warm) | B Information-Dense(notion) | C Visual-First(screen-print). Or specify style directly: cute/fresh/warm/bold/minimal/retro/pop/notion/chalkboard/study-notes/screen-print/sketch-notes. Or use preset: knowledge-card / checklist / tutorial / poster / hand-drawn-edu / etc.
|
||||
2. **布局**: Current: [layout]. Options: sparse | balanced | dense | list | comparison | flow | mindmap | quadrant
|
||||
3. **图片数量**: Current: [N]. Range: 2-10
|
||||
4. **补充说明**(可选): Selling point emphasis, audience adjustment, color preference, etc.
|
||||
3. **配色**: Current: [palette or "默认"]. Options: 默认 | macaron | warm | neon
|
||||
4. **图片数量**: Current: [N]. Range: 2-10
|
||||
5. **补充说明**(可选): Selling point emphasis, audience adjustment, custom color preference, etc.
|
||||
|
||||
**After response**: Generate single outline with user's choices → save to `outline.md` → Step 3.
|
||||
|
||||
@@ -430,6 +460,7 @@ Full two-confirmation flow for maximum control:
|
||||
strategy: a # a, b, or c
|
||||
name: Story-Driven
|
||||
style: warm # recommended style for this strategy
|
||||
palette: ~ # optional palette override (macaron, warm, neon, or ~ for style default)
|
||||
style_reason: "Warm tones enhance emotional storytelling and personal connection"
|
||||
elements: # from style preset, can be customized
|
||||
background: solid-pastel
|
||||
@@ -517,12 +548,13 @@ If image generation skill supports `--sessionId`:
|
||||
### Step 4: Completion Report
|
||||
|
||||
```
|
||||
Xiaohongshu Infographic Series Complete!
|
||||
Xiaohongshu Image Card Series Complete!
|
||||
|
||||
Topic: [topic]
|
||||
Mode: [Quick / Custom / Detailed]
|
||||
Strategy: [A/B/C/Combined]
|
||||
Style: [style name]
|
||||
Palette: [palette name or "default"]
|
||||
Layout: [layout name or "varies"]
|
||||
Location: [directory path]
|
||||
Images: N total
|
||||
@@ -569,6 +601,7 @@ Files:
|
||||
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| study-notes | ✗ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
|
||||
| screen-print | ✓✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| sketch-notes | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
## References
|
||||
|
||||
@@ -582,7 +615,10 @@ Detailed templates in `references/` directory:
|
||||
|
||||
**Presets** (Style presets):
|
||||
- `presets/<name>.md` - Element combination definitions (cute, notion, warm...)
|
||||
- `style-presets.md` - Preset shortcuts (style + layout combos)
|
||||
- `style-presets.md` - Preset shortcuts (style + layout + palette combos)
|
||||
|
||||
**Palettes** (Color overrides):
|
||||
- `palettes/<name>.md` - Color palette definitions (macaron, warm, neon)
|
||||
|
||||
**Workflows** (Process guides):
|
||||
- `workflows/analysis-framework.md` - Content analysis framework
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Macaron Palette
|
||||
|
||||
Soft pastel color blocks on warm cream background. Gentle, approachable, educational feel.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Warm cream (#F5F0E8)
|
||||
- Texture: Subtle paper grain, warm tone
|
||||
|
||||
## Colors
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Warm Cream | #F5F0E8 | Primary background |
|
||||
| Text | Deep Charcoal | #2C3E50 | Titles, main content |
|
||||
| Secondary Text | Warm Gray | #6B6B6B | Annotations, labels |
|
||||
| Block Color | Macaron Blue | #A8D8EA | Content block fill |
|
||||
| Block Color | Macaron Lavender | #D5C6E0 | Content block fill |
|
||||
| Block Color | Macaron Mint | #B5E5CF | Content block fill |
|
||||
| Block Color | Macaron Peach | #F8D5C4 | Content block fill |
|
||||
| Accent | Coral Red | #E8655A | Emphasis, highlights |
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Soft pastel macaron color palette. Use block colors as rounded card backgrounds for distinct information sections. Accent coral red sparingly for emphasis on key terms only. All colors should feel gentle and approachable — no saturated or neon tones. Do NOT render color names or role labels as visible text in the image.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
- `sketch-notes` — natural pairing for hand-drawn educational content
|
||||
- `notion` — macaron accents soften the monochrome aesthetic
|
||||
- `chalkboard` — pastel chalk tones replace standard chalk colors
|
||||
- `warm` — reinforces the cozy, friendly feel
|
||||
- `fresh` — complements the clean, natural aesthetic
|
||||
@@ -0,0 +1,32 @@
|
||||
# Neon Palette
|
||||
|
||||
Vibrant neon colors on dark background. High-energy, futuristic, eye-catching.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Dark Purple (#1A1025)
|
||||
- Texture: Smooth, deep
|
||||
|
||||
## Colors
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Dark Purple | #1A1025 | Primary background |
|
||||
| Text | Bright White | #F0F0F0 | Titles, main content |
|
||||
| Secondary Text | Light Lavender | #B8B8D4 | Annotations, labels |
|
||||
| Block Color | Neon Cyan | #00F5FF | Content block fill |
|
||||
| Block Color | Neon Magenta | #FF00FF | Content block fill |
|
||||
| Block Color | Neon Green | #39FF14 | Content block fill |
|
||||
| Block Color | Neon Pink | #FF6EC7 | Content block fill |
|
||||
| Accent | Electric Yellow | #FFFF00 | Emphasis, highlights |
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Vibrant neon color palette on dark background. Colors should glow against the dark base. High contrast, futuristic feel. Use neon sparingly — too many glowing elements become chaotic. Let dark background breathe.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
- `bold` — amplifies high-impact energy
|
||||
- `pop` — neon takes the vibrancy further
|
||||
- `minimal` — neon accents on dark create striking contrast
|
||||
- `notion` — futuristic knowledge card aesthetic
|
||||
@@ -0,0 +1,32 @@
|
||||
# Warm Palette
|
||||
|
||||
Warm earth tones on soft peach background. Cozy, inviting, no cool colors.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Soft Peach (#FFECD2)
|
||||
- Texture: Warm, slightly textured
|
||||
|
||||
## Colors
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Soft Peach | #FFECD2 | Primary background |
|
||||
| Text | Deep Brown | #744210 | Titles, main content |
|
||||
| Secondary Text | Warm Brown | #9C6644 | Annotations, labels |
|
||||
| Block Color | Warm Orange | #ED8936 | Content block fill |
|
||||
| Block Color | Terracotta | #C05621 | Content block fill |
|
||||
| Block Color | Golden Yellow | #F6AD55 | Content block fill |
|
||||
| Block Color | Dusty Rose | #D4A09A | Content block fill |
|
||||
| Accent | Burnt Sienna | #A0522D | Emphasis, highlights |
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Warm-only color palette, no cool colors (no blue, green, purple). Earth tones throughout. Evokes comfort, warmth, and trust. All colors should feel like autumn sunlight.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
- `warm` — natural pairing, amplifies cozy feel
|
||||
- `cute` — warm pastels enhance the sweet aesthetic
|
||||
- `retro` — earth tones complement vintage style
|
||||
- `sketch-notes` — warm educational feel
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: sketch-notes
|
||||
category: educational
|
||||
default_palette: macaron
|
||||
---
|
||||
|
||||
# Sketch Notes Style
|
||||
|
||||
Hand-drawn educational infographic with slight line wobble, like a high-quality presentation visual summary.
|
||||
|
||||
## Element Combination
|
||||
|
||||
```yaml
|
||||
canvas:
|
||||
ratio: portrait-3-4
|
||||
grid: single | dual
|
||||
|
||||
image_effects:
|
||||
cutout: stylized
|
||||
stroke: none
|
||||
filter: none
|
||||
|
||||
typography:
|
||||
decorated: handwritten
|
||||
tags: rounded-badge
|
||||
direction: horizontal
|
||||
|
||||
decorations:
|
||||
emphasis: underline | circle-mark | arrows-curvy | star-burst
|
||||
background: paper-texture
|
||||
doodles: hand-drawn-lines | stars-sparkles | arrows-curvy | squiggles
|
||||
frames: rounded-rect
|
||||
```
|
||||
|
||||
## Color Palette
|
||||
|
||||
Default: **macaron** palette (see `palettes/macaron.md`)
|
||||
|
||||
When no `--palette` is specified, uses macaron colors: warm cream background (#F5F0E8), macaron blue/lavender/mint/peach zone blocks, coral red accent.
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Hand-drawn wobble on all lines and shapes
|
||||
- Simple stick-figure characters at desks, working, thinking
|
||||
- Rounded cards with pastel color blocks as information sections
|
||||
- Color fills do NOT completely fill outlines (hand-painted feel)
|
||||
- Doodle decorations: small stars, underlines, checkmarks, lock icons, clipboard icons
|
||||
- Wavy hand-drawn arrows connecting zones with small text labels
|
||||
- Thought bubbles and speech bubbles with sketchy outlines
|
||||
- Simple conceptual icons (documents, lightbulbs, gears, arrows)
|
||||
- Generous whitespace between zones for clean composition
|
||||
|
||||
## Typography
|
||||
|
||||
- Bold hand-drawn lettering for titles (large, prominent)
|
||||
- Bold keywords within content zones
|
||||
- Smaller annotations in secondary text color
|
||||
- Hand-drawn quality on ALL text, no computer-generated fonts
|
||||
- Clear information hierarchy: title > zone labels > body text > annotations
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
- Maintain slight wobble on every line, shape, and border
|
||||
- Use palette block colors as distinct section backgrounds
|
||||
- Leave color fills intentionally incomplete at edges
|
||||
- Include simple doodle icons relevant to content
|
||||
- Keep generous whitespace between zones
|
||||
- Use accent color sparingly for emphasis on key terms
|
||||
- Draw connecting arrows with hand-drawn wavy feel
|
||||
|
||||
### Don't
|
||||
- Use perfect geometric shapes or straight lines
|
||||
- Create photorealistic elements
|
||||
- Fill colors completely to edges (maintain hand-painted gap)
|
||||
- Use dark or saturated backgrounds
|
||||
- Overcrowd with too many decorative elements
|
||||
- Use gradient fills or glossy effects
|
||||
|
||||
## Best Layout Pairings
|
||||
|
||||
| Layout | Compatibility | Use Case |
|
||||
|--------|---------------|----------|
|
||||
| sparse | ✓ | Simple covers with single zone |
|
||||
| balanced | ✓✓ | Standard educational summaries |
|
||||
| dense | ✓✓ | Knowledge cards, concept maps |
|
||||
| list | ✓✓ | Step-by-step guides, checklists |
|
||||
| comparison | ✓ | Side-by-side concept contrast |
|
||||
| flow | ✓✓ | Process diagrams, workflows, tutorials |
|
||||
| mindmap | ✓✓ | Concept maps, radial knowledge maps |
|
||||
| quadrant | ✓ | Classification matrices |
|
||||
|
||||
## Best For
|
||||
|
||||
- Educational content, tutorials, how-to guides
|
||||
- Process and workflow explanations
|
||||
- Knowledge summaries, concept diagrams
|
||||
- Technical explanations made approachable
|
||||
- Visual summaries of articles or talks
|
||||
- Onboarding materials, friendly guides
|
||||
@@ -1,36 +1,43 @@
|
||||
# Style Presets
|
||||
|
||||
`--preset X` expands to a style + layout combination. Users can override either dimension.
|
||||
`--preset X` expands to a style + layout + optional palette combination. Users can override any dimension.
|
||||
|
||||
| --preset | Style | Layout |
|
||||
|----------|-------|--------|
|
||||
| `knowledge-card` | `notion` | `dense` |
|
||||
| `checklist` | `notion` | `list` |
|
||||
| `concept-map` | `notion` | `mindmap` |
|
||||
| `swot` | `notion` | `quadrant` |
|
||||
| `tutorial` | `chalkboard` | `flow` |
|
||||
| `classroom` | `chalkboard` | `balanced` |
|
||||
| `study-guide` | `study-notes` | `dense` |
|
||||
| `cute-share` | `cute` | `balanced` |
|
||||
| `girly` | `cute` | `sparse` |
|
||||
| `cozy-story` | `warm` | `balanced` |
|
||||
| `product-review` | `fresh` | `comparison` |
|
||||
| `nature-flow` | `fresh` | `flow` |
|
||||
| `warning` | `bold` | `list` |
|
||||
| `versus` | `bold` | `comparison` |
|
||||
| `clean-quote` | `minimal` | `sparse` |
|
||||
| `pro-summary` | `minimal` | `balanced` |
|
||||
| `retro-ranking` | `retro` | `list` |
|
||||
| `throwback` | `retro` | `balanced` |
|
||||
| `pop-facts` | `pop` | `list` |
|
||||
| `hype` | `pop` | `sparse` |
|
||||
| `poster` | `screen-print` | `sparse` |
|
||||
| `editorial` | `screen-print` | `balanced` |
|
||||
| `cinematic` | `screen-print` | `comparison` |
|
||||
| --preset | Style | Layout | Palette |
|
||||
|----------|-------|--------|---------|
|
||||
| `knowledge-card` | `notion` | `dense` | |
|
||||
| `checklist` | `notion` | `list` | |
|
||||
| `concept-map` | `notion` | `mindmap` | |
|
||||
| `swot` | `notion` | `quadrant` | |
|
||||
| `tutorial` | `chalkboard` | `flow` | |
|
||||
| `classroom` | `chalkboard` | `balanced` | |
|
||||
| `study-guide` | `study-notes` | `dense` | |
|
||||
| `cute-share` | `cute` | `balanced` | |
|
||||
| `girly` | `cute` | `sparse` | |
|
||||
| `cozy-story` | `warm` | `balanced` | |
|
||||
| `product-review` | `fresh` | `comparison` | |
|
||||
| `nature-flow` | `fresh` | `flow` | |
|
||||
| `warning` | `bold` | `list` | |
|
||||
| `versus` | `bold` | `comparison` | |
|
||||
| `clean-quote` | `minimal` | `sparse` | |
|
||||
| `pro-summary` | `minimal` | `balanced` | |
|
||||
| `retro-ranking` | `retro` | `list` | |
|
||||
| `throwback` | `retro` | `balanced` | |
|
||||
| `pop-facts` | `pop` | `list` | |
|
||||
| `hype` | `pop` | `sparse` | |
|
||||
| `poster` | `screen-print` | `sparse` | |
|
||||
| `editorial` | `screen-print` | `balanced` | |
|
||||
| `cinematic` | `screen-print` | `comparison` | |
|
||||
| `hand-drawn-edu` | `sketch-notes` | `flow` | `macaron` |
|
||||
| `sketch-card` | `sketch-notes` | `dense` | `macaron` |
|
||||
| `sketch-summary` | `sketch-notes` | `balanced` | `macaron` |
|
||||
|
||||
Empty Palette = use style's built-in colors (or style's `default_palette` if defined in frontmatter).
|
||||
|
||||
## Override Examples
|
||||
|
||||
- `--preset knowledge-card --style chalkboard` = chalkboard style with dense layout
|
||||
- `--preset poster --layout quadrant` = screen-print style with quadrant layout
|
||||
- `--preset hand-drawn-edu --palette warm` = sketch-notes style with flow layout, warm palette instead of macaron
|
||||
- `--style notion --palette macaron` = notion rendering rules with macaron colors
|
||||
|
||||
Explicit `--style`/`--layout` flags always override preset values.
|
||||
Explicit `--style`/`--layout`/`--palette` flags always override preset values.
|
||||
|
||||
@@ -112,6 +112,37 @@ When `style: screen-print`, replace the standard Core Principles and Text Style
|
||||
- Paper grain texture beneath all colors
|
||||
```
|
||||
|
||||
## Palette Override
|
||||
|
||||
When `--palette` is specified (or style has `default_palette` in frontmatter and no explicit `--palette`), palette colors **replace** the style's Color Palette in the prompt. Style rendering rules (Visual Elements, Typography, Style Rules) remain unchanged.
|
||||
|
||||
Load from `palettes/{palette}.md` and override:
|
||||
|
||||
```markdown
|
||||
## Palette Override: {palette_name}
|
||||
|
||||
**Background**: {palette background color and hex}
|
||||
|
||||
**Colors**:
|
||||
- Text: {text color and hex}
|
||||
- Secondary: {secondary text color and hex}
|
||||
- Zone 1: {zone color and hex}
|
||||
- Zone 2: {zone color and hex}
|
||||
- Zone 3: {zone color and hex}
|
||||
- Zone 4: {zone color and hex}
|
||||
- Accent: {accent color and hex}
|
||||
|
||||
**Constraint**: {semantic constraint from palette}
|
||||
```
|
||||
|
||||
**Override rules**:
|
||||
1. Palette Background **replaces** style's background color (keep style's texture description)
|
||||
2. Palette Colors **replace** style's Color Palette section entirely
|
||||
3. Palette Semantic Constraint is appended to the style section
|
||||
4. If no `--palette` and style has `default_palette` → load that palette
|
||||
5. If no `--palette` and no `default_palette` → use style's built-in colors (no override)
|
||||
6. Explicit `--palette` always overrides style's `default_palette`
|
||||
|
||||
## Layout Section Assembly
|
||||
|
||||
Load from `elements/canvas.md` and extract relevant layout:
|
||||
@@ -159,26 +190,38 @@ be legible but not distracting from the main content.
|
||||
|
||||
### Step 0: Resolve Style Preset (if `--preset` used)
|
||||
|
||||
If user specified `--preset`, resolve to style + layout from `references/style-presets.md`:
|
||||
If user specified `--preset`, resolve to style + layout + palette from `references/style-presets.md`:
|
||||
|
||||
```python
|
||||
# e.g., --preset knowledge-card → style=notion, layout=dense
|
||||
style, layout = resolve_preset(preset_name)
|
||||
# e.g., --preset hand-drawn-edu → style=sketch-notes, layout=flow, palette=macaron
|
||||
style, layout, palette = resolve_preset(preset_name)
|
||||
```
|
||||
|
||||
Explicit `--style`/`--layout` flags override preset values.
|
||||
Explicit `--style`/`--layout`/`--palette` flags override preset values.
|
||||
|
||||
### Step 1: Load Style Definition
|
||||
|
||||
```python
|
||||
preset = load_preset(style_name) # e.g., "notion"
|
||||
preset = load_preset(style_name) # e.g., "sketch-notes"
|
||||
```
|
||||
|
||||
Extract:
|
||||
- Color palette
|
||||
- Color palette (may be overridden by palette)
|
||||
- Visual elements
|
||||
- Typography style
|
||||
- Best practices (do/don't)
|
||||
- `default_palette` from frontmatter (if present)
|
||||
|
||||
### Step 1.5: Apply Palette Override (if applicable)
|
||||
|
||||
```python
|
||||
# Priority: explicit --palette > preset palette > style default_palette > none
|
||||
palette = resolve_palette(cli_palette, preset_palette, style_default_palette)
|
||||
if palette:
|
||||
palette_def = load_palette(palette) # e.g., "macaron"
|
||||
# Replace style colors with palette colors
|
||||
# Keep style rendering rules (visual elements, typography, style rules)
|
||||
```
|
||||
|
||||
### Step 2: Load Layout
|
||||
|
||||
@@ -327,6 +370,7 @@ Please use nano banana pro to generate the infographic based on the specificatio
|
||||
Before generating, verify:
|
||||
|
||||
- [ ] Style section loaded from correct preset
|
||||
- [ ] Palette override applied (if `--palette` specified or style has `default_palette`)
|
||||
- [ ] Layout section matches outline specification
|
||||
- [ ] Content accurately reflects outline entry
|
||||
- [ ] Language matches source content
|
||||
|
||||
Reference in New Issue
Block a user