mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8ad82786 | |||
| 6fffe252c0 | |||
| 86a84739e8 | |||
| 7f80100b3e | |||
| 61960961ba | |||
| cdd9ee042d | |||
| c742bfa1af | |||
| 0faea4ecaa | |||
| 7c9ec259a1 | |||
| 24a17709a8 | |||
| a3849af0cf | |||
| cc95e6fe05 | |||
| e7d9ed7917 | |||
| 876c6332f6 | |||
| 502d2448b2 | |||
| b9e48d9483 | |||
| b0554f8d0c | |||
| 12f3d95639 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.28.1"
|
||||
"version": "1.30.1"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.30.1 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add OpenAI GPT Image edits support for reference images (`--ref`); auto-select Google or OpenAI when ref provided.
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: change ref-related warnings to explicit errors with fix hints; add reference image validation.
|
||||
- `baoyu-cover-image`: enhance reference image analysis with deep extraction template; require MUST INCORPORATE section for concrete visual elements.
|
||||
|
||||
## 1.30.0 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-cover-image`: add font dimension with 4 typography styles (clean, handwritten, serif, display); includes auto-selection rules, compatibility matrix, and `warm-flat` style preset.
|
||||
|
||||
## 1.29.0 - 2026-02-06
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add EXTEND.md configuration support, including schema documentation and runtime preference loading in scripts (by @kingdomad).
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix duplicated title and ordered-list numbering in WeChat article publishing (by @NantesCheval).
|
||||
- `baoyu-url-to-markdown`: replace regex-only conversion with multi-strategy content extraction and Turndown conversion; improve noise filtering for Substack-style pages.
|
||||
|
||||
## 1.28.4 - 2026-02-03
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: add author and description meta tags to generated HTML from YAML frontmatter; strip quotes from frontmatter values (supports both English and Chinese quotation marks).
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: remove extra empty lines after image paste; fix summary field timing to fill after content paste (prevents being overwritten).
|
||||
|
||||
## 1.28.3 - 2026-02-03
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix placeholder matching issue where `WECHATIMGPH_1` incorrectly matched `WECHATIMGPH_10`.
|
||||
|
||||
## 1.28.2 - 2026-02-03
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-x`: reuse existing Chrome instance when available; fix placeholder matching issue where `XIMGPH_1` incorrectly matched `XIMGPH_10`; improve image sorting by placeholder index; use `execCommand` for more reliable placeholder deletion.
|
||||
|
||||
## 1.28.1 - 2026-02-02
|
||||
|
||||
### Refactor
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.30.1 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 OpenAI GPT Image edits 支持参考图片(`--ref`);提供 ref 时自动选择 Google 或 OpenAI。
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:将 ref 相关警告改为明确错误提示;新增参考图片验证。
|
||||
- `baoyu-cover-image`:增强参考图片分析,使用深度提取模板;要求 MUST INCORPORATE 章节以包含具体可复现的视觉元素。
|
||||
|
||||
## 1.30.0 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-cover-image`:新增字体维度,支持 4 种字体风格(clean、handwritten、serif、display);包含自动选择规则、兼容性矩阵和 `warm-flat` 风格预设。
|
||||
|
||||
## 1.29.0 - 2026-02-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 EXTEND.md 配置支持,补充配置 schema 文档并在脚本运行时读取偏好设置 (by @kingdomad)。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复公众号文章发布时标题和有序列表编号重复问题 (by @NantesCheval)。
|
||||
- `baoyu-url-to-markdown`:将正则转换升级为多策略正文抽取 + Turndown 转换,提升 Substack 类页面的噪声过滤能力。
|
||||
|
||||
## 1.28.4 - 2026-02-03
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:从 YAML frontmatter 生成 author 和 description meta 标签;自动去除 frontmatter 值两端的引号(支持中英文引号)。
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:移除图片粘贴后产生的多余空行;修复摘要填充时机,改为内容粘贴后填写(避免被覆盖)。
|
||||
|
||||
## 1.28.3 - 2026-02-03
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复占位符匹配问题(`WECHATIMGPH_1` 错误匹配 `WECHATIMGPH_10`)。
|
||||
|
||||
## 1.28.2 - 2026-02-03
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-x`:复用已有 Chrome 实例;修复占位符匹配问题(`XIMGPH_1` 错误匹配 `XIMGPH_10`);改进图片按占位符序号排序;使用 `execCommand` 提高占位符删除可靠性。
|
||||
|
||||
## 1.28.1 - 2026-02-02
|
||||
|
||||
### 重构
|
||||
|
||||
@@ -50,6 +50,7 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
| `--style <name>` | Preset shorthand (expands to palette + rendering, see [Style Presets](references/style-presets.md)) |
|
||||
| `--text <level>` | Text density: none, title-only, title-subtitle, text-rich |
|
||||
| `--mood <level>` | Emotional intensity: subtle, balanced, bold |
|
||||
| `--font <name>` | Font style: clean, handwritten, serif, display |
|
||||
| `--aspect <ratio>` | 16:9 (default), 2.35:1, 4:3, 3:2, 1:1, 3:4 |
|
||||
| `--lang <code>` | Title language (en, zh, ja, etc.) |
|
||||
| `--no-title` | Alias for `--text none` |
|
||||
@@ -65,6 +66,7 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
| **Rendering** | Line quality, texture, depth, element style | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
|
||||
| **Text** | Text density, information hierarchy | none, title-only, title-subtitle, text-rich | title-only |
|
||||
| **Mood** | Emotional intensity, visual weight | subtle, balanced, bold | balanced |
|
||||
| **Font** | Typography style, character feel | clean, handwritten, serif, display | clean |
|
||||
|
||||
Dimensions can be freely combined. Auto-selection rules: [references/auto-selection.md](references/auto-selection.md)
|
||||
|
||||
@@ -127,6 +129,17 @@ Rendering definitions: [references/renderings/](references/renderings/)
|
||||
|
||||
Full guides: [references/dimensions/text.md](references/dimensions/text.md) | [references/dimensions/mood.md](references/dimensions/mood.md)
|
||||
|
||||
## Font Gallery
|
||||
|
||||
| Font | Description | Best For |
|
||||
|------|-------------|----------|
|
||||
| `clean` | Modern geometric sans-serif | Tech, professional, minimal |
|
||||
| `handwritten` | Warm hand-lettered style | Personal, friendly, lifestyle |
|
||||
| `serif` | Classic elegant typography | Editorial, academic, luxury |
|
||||
| `display` | Bold decorative headlines | Announcements, entertainment |
|
||||
|
||||
Full guide: [references/dimensions/font.md](references/dimensions/font.md)
|
||||
|
||||
## Style Presets & Compatibility
|
||||
|
||||
- **Style Presets**: `--style X` expands to palette + rendering. See [references/style-presets.md](references/style-presets.md)
|
||||
@@ -175,7 +188,7 @@ Cover Image Progress:
|
||||
- [ ] File path given → saved to refs/ ✓
|
||||
- [ ] No path → asked user OR extracted verbally
|
||||
- [ ] 1.2 Output directory determined
|
||||
- [ ] Step 2: Confirm options (5 dimensions) ⚠️ REQUIRED unless --quick or all specified
|
||||
- [ ] Step 2: Confirm options (6 dimensions) ⚠️ REQUIRED unless --quick or all specified
|
||||
- [ ] Step 3: Create prompt
|
||||
- [ ] References in prompt ONLY if files exist in refs/
|
||||
- [ ] Extracted style/palette appended to prompt body (if no file)
|
||||
@@ -196,7 +209,7 @@ Input → [Step 0: Preferences] ─┬─ Found → Continue
|
||||
│
|
||||
┌───────────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
Analyze + Save Refs → [Output Dir ⚠️] → [Confirm: 5 Dimensions] → Prompt → Generate → Complete
|
||||
Analyze + Save Refs → [Output Dir ⚠️] → [Confirm: 6 Dimensions] → Prompt → Generate → Complete
|
||||
↓
|
||||
(skip if --quick or all specified)
|
||||
```
|
||||
@@ -233,7 +246,7 @@ Preferences loaded from [project/user]:
|
||||
• Quick mode: [enabled/disabled] | Language: [value or "auto"]
|
||||
```
|
||||
|
||||
**EXTEND.md Supports**: Watermark | Preferred type | Preferred palette | Preferred rendering | Preferred text | Preferred mood | Default aspect ratio | Default output directory | Quick mode | Custom palette definitions | Language preference
|
||||
**EXTEND.md Supports**: Watermark | Preferred type | Preferred palette | Preferred rendering | Preferred text | Preferred mood | Preferred font | Default aspect ratio | Default output directory | Quick mode | Custom palette definitions | Language preference
|
||||
|
||||
Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md)
|
||||
|
||||
@@ -304,16 +317,23 @@ Reference Style Extracted (no file):
|
||||
- Identify visual metaphors
|
||||
- Detect content type
|
||||
|
||||
**1.3 Reference Image Analysis** (if provided in Step 1.0)
|
||||
**1.3 Reference Image Deep Analysis** ⚠️ CRITICAL (if provided in Step 1.0)
|
||||
|
||||
For each reference image:
|
||||
References are high-priority inputs. Perform deep visual analysis to extract **specific, concrete, reproducible** elements — not vague summaries.
|
||||
|
||||
| Analysis | Description |
|
||||
|----------|-------------|
|
||||
| Visual characteristics | Style, colors, composition |
|
||||
| Content/subject | What the reference depicts |
|
||||
| Style match | Which type/palette/rendering align |
|
||||
| Usage recommendation | `direct` / `style` / `palette` |
|
||||
For each reference image, extract ALL of the following:
|
||||
|
||||
| Analysis | Description | Example (good vs bad) |
|
||||
|----------|-------------|----------------------|
|
||||
| **Brand elements** | Logos, wordmarks, specific typography treatments | Good: "Logo uses vertical parallel lines for 'm'" / Bad: "Has a logo" |
|
||||
| **Signature patterns** | Unique decorative motifs, textures, geometric patterns | Good: "Woven intersecting curves forming diamond grid" / Bad: "Has patterns" |
|
||||
| **Color palette** | Exact hex values or close approximations for key colors | Good: "#2D4A3E dark teal, #F5F0E0 cream" / Bad: "Dark and light colors" |
|
||||
| **Layout structure** | Specific spatial arrangement, banner placement, zone splits | Good: "Bottom 30% dark banner with branding" / Bad: "Has a banner" |
|
||||
| **Typography** | Font style, weight, spacing, case, positioning | Good: "Uppercase, wide letter-spacing, handwritten ep number" / Bad: "Has text" |
|
||||
| **Content/subject** | What the reference depicts | Factual description |
|
||||
| **Usage recommendation** | `direct` / `style` / `palette` | Based on analysis |
|
||||
|
||||
**Output format**: List each element as a bullet point that can be directly copy-pasted into a prompt as a mandatory instruction.
|
||||
|
||||
**1.4 Language Detection**
|
||||
- Detect source language
|
||||
@@ -326,14 +346,14 @@ For each reference image:
|
||||
|
||||
### Step 2: Confirm Options ⚠️
|
||||
|
||||
Validate all 5 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
Validate all 6 dimensions + aspect ratio. Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
|
||||
**Skip Conditions**:
|
||||
|
||||
| Condition | Skipped | Still Asked |
|
||||
|-----------|---------|-------------|
|
||||
| `--quick` or `quick_mode: true` | 5 dimensions | Aspect ratio (unless `--aspect`) |
|
||||
| All 5 + `--aspect` specified | All | None |
|
||||
| `--quick` or `quick_mode: true` | 6 dimensions | Aspect ratio (unless `--aspect`) |
|
||||
| All 6 + `--aspect` specified | All | None |
|
||||
|
||||
### Step 3: Create Prompt
|
||||
|
||||
@@ -367,10 +387,15 @@ references:
|
||||
|
||||
| Situation | Frontmatter | Body |
|
||||
|-----------|-------------|------|
|
||||
| Reference file saved to `refs/` | Add to `references` ✓ | Brief style note |
|
||||
| Reference file saved to `refs/` | Add to `references` ✓ | **Detailed mandatory style instructions** (see below) |
|
||||
| Style extracted verbally (no file) | Omit `references` | Full style description |
|
||||
| File in frontmatter but doesn't exist | ERROR - fix or remove | — |
|
||||
|
||||
⚠️ **"Brief style note" is NOT sufficient.** When references are provided, the prompt body MUST contain a dedicated `# Reference Style — MUST INCORPORATE` section with:
|
||||
1. Per-reference breakdown of **specific visual elements** extracted in Step 1.3
|
||||
2. Each element prefixed with "MUST" or "REQUIRED"
|
||||
3. An **integration approach** describing exactly how reference elements should be composed into the cover layout (e.g., "Use a split layout: illustration area 65% + dark branded banner 35%")
|
||||
|
||||
### Step 4: Generate Image
|
||||
|
||||
**4.1 Backup existing** `cover.png` → `cover-backup-YYYYMMDD-HHMMSS.png` (if regenerating)
|
||||
@@ -408,8 +433,8 @@ references:
|
||||
|
||||
| Skill Supports `--ref` | Action |
|
||||
|------------------------|--------|
|
||||
| Yes (e.g., baoyu-image-gen with Google) | Pass reference images via `--ref` |
|
||||
| No | Convert to text description, append to prompt |
|
||||
| Yes (e.g., baoyu-image-gen with Google/OpenAI) | Pass reference images via `--ref` |
|
||||
| No | If usage is `direct`, switch to a ref-capable backend (baoyu-image-gen + Google multimodal or OpenAI GPT Image edits). If unavailable, convert to text description and remove direct refs |
|
||||
|
||||
**Verification**: Before generating, confirm reference processing:
|
||||
```
|
||||
@@ -421,7 +446,7 @@ Reference Processing:
|
||||
**4.4 Generate**
|
||||
|
||||
1. Call selected skill with prompt file path, output path (`cover.png`), aspect ratio
|
||||
2. If references with `direct` usage AND skill supports `--ref`: include `--ref` parameter
|
||||
2. If references with `direct` usage: use ref-capable backend and include `--ref` (recommend `baoyu-image-gen --provider google --model gemini-3-pro-image-preview` or `--provider openai --model gpt-image-1.5`)
|
||||
3. On failure: auto-retry once before reporting error
|
||||
|
||||
### Step 5: Completion Report
|
||||
@@ -431,7 +456,7 @@ Cover Generated!
|
||||
|
||||
Topic: [topic]
|
||||
Type: [type] | Palette: [palette] | Rendering: [rendering]
|
||||
Text: [text] | Mood: [mood] | Aspect: [ratio]
|
||||
Text: [text] | Mood: [mood] | Font: [font] | Aspect: [ratio]
|
||||
Title: [title text or "visual only"]
|
||||
Language: [lang] | Watermark: [enabled/disabled]
|
||||
References: [N images (direct/style/palette) or "extracted style" or "none"]
|
||||
@@ -463,13 +488,22 @@ All modifications automatically backup existing `cover.png` before regenerating.
|
||||
- Cover must be readable at small preview sizes
|
||||
- Visual metaphors > literal representations
|
||||
- Title: readable, impactful
|
||||
- Two confirmation points: Step 0 (first-time setup) + Step 2 (options) - skip Step 2 with `--quick`
|
||||
- Two confirmation points: Step 0 (first-time setup) + Step 2 (6 dimensions) - skip Step 2 with `--quick`
|
||||
- Use confirmed language for title text
|
||||
- Maintain watermark consistency if enabled
|
||||
- Check compatibility matrices when selecting combinations
|
||||
- `--no-title` is alias for `--text none`
|
||||
- `--style` presets are backward-compatible; explicit `--palette`/`--rendering` override preset values
|
||||
|
||||
### Reference Image Priority ⚠️
|
||||
|
||||
When user provides reference images, they are **HIGH PRIORITY** and MUST strongly influence the generated cover:
|
||||
|
||||
- **References override defaults**: If reference images conflict with preferred palette/rendering, the reference visual identity takes precedence in the prompt
|
||||
- **Concrete > abstract**: Extract specific, reproducible visual elements (exact patterns, logo treatments, color hex values, layout structures) — not vague descriptions like "clean style"
|
||||
- **Mandatory language**: Use "MUST", "REQUIRED", "CRITICAL" in the prompt for reference elements — generic "follow the style" is insufficient for image generation models
|
||||
- **Visible in output**: After generation, verify that reference elements are actually present in the cover. If not, strengthen the prompt and regenerate
|
||||
|
||||
### Composition Principles
|
||||
|
||||
- **Generous whitespace**: 40-60% breathing room; avoid cluttered layouts
|
||||
@@ -485,7 +519,7 @@ All modifications automatically backup existing `cover.png` before regenerating.
|
||||
|
||||
## References
|
||||
|
||||
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md)
|
||||
**Dimensions**: [text.md](references/dimensions/text.md) | [mood.md](references/dimensions/mood.md) | [font.md](references/dimensions/font.md)
|
||||
**Palettes**: [references/palettes/](references/palettes/)
|
||||
**Renderings**: [references/renderings/](references/renderings/)
|
||||
**Auto-Selection**: [references/auto-selection.md](references/auto-selection.md)
|
||||
|
||||
@@ -58,3 +58,14 @@ Default: `title-only`
|
||||
| Launch, announcement, promotion, event, gaming, entertainment | `bold` |
|
||||
|
||||
Default: `balanced`
|
||||
|
||||
## Auto Font Selection
|
||||
|
||||
| Signals | Font |
|
||||
|---------|------|
|
||||
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
|
||||
| Technical, professional, clean, modern, minimal, data | `clean` |
|
||||
| Editorial, academic, luxury, classic, literary | `serif` |
|
||||
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
|
||||
|
||||
Default: `clean`
|
||||
|
||||
@@ -48,3 +48,12 @@
|
||||
| metaphor | ✓✓ | ✓✓ | ✓ |
|
||||
| scene | ✓✓ | ✓✓ | ✓ |
|
||||
| minimal | ✓✓ | ✓✓ | ✗ |
|
||||
|
||||
## Font × Rendering
|
||||
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: font-dimension
|
||||
description: Typography style dimension for cover images
|
||||
---
|
||||
|
||||
# Font Dimension
|
||||
|
||||
Controls typography style and character feel.
|
||||
|
||||
## Values
|
||||
|
||||
| Font | Visual Style | Line Quality | Character |
|
||||
|------|--------------|--------------|-----------|
|
||||
| `clean` | Geometric sans-serif | Sharp, uniform | Modern, precise, neutral |
|
||||
| `handwritten` | Hand-lettered, brush | Organic, varied | Warm, personal, friendly |
|
||||
| `serif` | Classic serifs, elegant | Refined, structured | Editorial, authoritative |
|
||||
| `display` | Bold, decorative | Heavy, expressive | Attention-grabbing, playful |
|
||||
|
||||
## Detail
|
||||
|
||||
### clean
|
||||
|
||||
Modern, universal typography with neutral character.
|
||||
|
||||
**Characteristics**:
|
||||
- Geometric sans-serif letterforms
|
||||
- Sharp, uniform line weight
|
||||
- Clean edges, no flourishes
|
||||
- High readability at all sizes
|
||||
- Minimal personality, maximum clarity
|
||||
|
||||
**Use Cases**:
|
||||
- Technical documentation
|
||||
- Professional/corporate content
|
||||
- Minimal design approaches
|
||||
- Data-driven articles
|
||||
- Modern brand aesthetics
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use clean geometric sans-serif typography
|
||||
- Modern, minimal letterforms
|
||||
- Sharp edges, uniform stroke weight
|
||||
- High contrast against background
|
||||
|
||||
### handwritten
|
||||
|
||||
Warm, organic typography with personal character.
|
||||
|
||||
**Characteristics**:
|
||||
- Hand-lettered or brush style
|
||||
- Organic, varied line weight
|
||||
- Natural imperfections
|
||||
- Approachable, human feel
|
||||
- Casual yet intentional
|
||||
|
||||
**Use Cases**:
|
||||
- Personal stories
|
||||
- Lifestyle content
|
||||
- Wellness and self-improvement
|
||||
- Creative tutorials
|
||||
- Friendly brand voices
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use warm hand-lettered typography with organic brush strokes
|
||||
- Friendly, personal feel
|
||||
- Natural variation in stroke weight
|
||||
- Approachable, human character
|
||||
|
||||
### serif
|
||||
|
||||
Classic, elegant typography with editorial authority.
|
||||
|
||||
**Characteristics**:
|
||||
- Traditional serif letterforms
|
||||
- Refined, structured strokes
|
||||
- Elegant proportions
|
||||
- Timeless sophistication
|
||||
- Formal, trustworthy feel
|
||||
|
||||
**Use Cases**:
|
||||
- Editorial content
|
||||
- Academic articles
|
||||
- Luxury brand content
|
||||
- Historical topics
|
||||
- Literary pieces
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use elegant serif typography with refined letterforms
|
||||
- Classic, editorial character
|
||||
- Structured, proportional spacing
|
||||
- Authoritative, sophisticated feel
|
||||
|
||||
### display
|
||||
|
||||
Bold, decorative typography for maximum impact.
|
||||
|
||||
**Characteristics**:
|
||||
- Heavy, expressive letterforms
|
||||
- Decorative elements
|
||||
- Strong visual presence
|
||||
- Playful or dramatic character
|
||||
- Designed for headlines
|
||||
|
||||
**Use Cases**:
|
||||
- Announcements
|
||||
- Entertainment content
|
||||
- Promotional materials
|
||||
- Event marketing
|
||||
- Gaming topics
|
||||
|
||||
**Prompt Hints**:
|
||||
- Use bold decorative display typography
|
||||
- Heavy, expressive headlines
|
||||
- Strong visual impact
|
||||
- Attention-grabbing character
|
||||
|
||||
## Default
|
||||
|
||||
`clean` — Universal, pairs well with most rendering styles.
|
||||
|
||||
## Rendering Compatibility
|
||||
|
||||
| Font × Rendering | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|------------------|:-----------:|:----------:|:---------:|:-------:|:-----:|:-----:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
|
||||
|
||||
## Type Compatibility
|
||||
|
||||
| Font × Type | hero | conceptual | typography | metaphor | scene | minimal |
|
||||
|-------------|:----:|:----------:|:----------:|:--------:|:-----:|:-------:|
|
||||
| clean | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| handwritten | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| serif | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| display | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✗ |
|
||||
|
||||
## Palette Interaction
|
||||
|
||||
Font style adapts to palette characteristics:
|
||||
|
||||
| Palette Category | clean | handwritten | serif | display |
|
||||
|------------------|-------|-------------|-------|---------|
|
||||
| Warm (warm, earth, pastel) | Softer weight | Natural fit | Warm tones | Playful energy |
|
||||
| Cool (cool, mono, elegant) | Perfect match | Contrast | Classic pairing | Bold statement |
|
||||
| Dark (dark, vivid) | High contrast | Glow effects | Dramatic | Maximum impact |
|
||||
| Vintage (retro) | Modern contrast | Nostalgic fit | Period-appropriate | Retro headlines |
|
||||
|
||||
## Auto Selection
|
||||
|
||||
When `--font` is omitted, select based on signals:
|
||||
|
||||
| Signals | Font |
|
||||
|---------|------|
|
||||
| Personal, lifestyle, human, warm, friendly, story | `handwritten` |
|
||||
| Technical, professional, clean, modern, minimal, data | `clean` |
|
||||
| Editorial, academic, luxury, classic, literary | `serif` |
|
||||
| Announcement, entertainment, promotion, bold, event, gaming | `display` |
|
||||
|
||||
Default: `clean`
|
||||
@@ -22,6 +22,7 @@
|
||||
| `vector-illustration` | `retro` | `flat-vector` |
|
||||
| `vintage` | `retro` | `hand-drawn` |
|
||||
| `warm` | `warm` | `hand-drawn` |
|
||||
| `warm-flat` | `warm` | `flat-vector` |
|
||||
| `watercolor` | `earth` | `painterly` |
|
||||
|
||||
## Override Examples
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Validate all 5 dimensions + aspect ratio.
|
||||
Validate all 6 dimensions + aspect ratio.
|
||||
|
||||
## Skip Conditions
|
||||
|
||||
| Condition | Skipped Questions | Still Asked |
|
||||
|-----------|-------------------|-------------|
|
||||
| `--quick` flag | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| All 5 dimensions + `--aspect` specified | All | None |
|
||||
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| Otherwise | None | All 6 questions |
|
||||
| `--quick` flag | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| All 6 dimensions + `--aspect` specified | All | None |
|
||||
| `quick_mode: true` in EXTEND.md | Type, Palette, Rendering, Text, Mood, Font | **Aspect Ratio** (unless `--aspect` specified) |
|
||||
| Otherwise | None | All 7 questions |
|
||||
|
||||
**Important**: Aspect ratio is ALWAYS asked unless explicitly specified via `--aspect` CLI flag. User presets in EXTEND.md are shown as recommended option, not auto-selected.
|
||||
|
||||
## Quick Mode Output
|
||||
|
||||
When skipping 5 dimensions:
|
||||
When skipping 6 dimensions:
|
||||
|
||||
```
|
||||
Quick Mode: Auto-selected dimensions
|
||||
@@ -26,8 +26,9 @@ Quick Mode: Auto-selected dimensions
|
||||
• Rendering: [rendering] ([reason])
|
||||
• Text: [text] ([reason])
|
||||
• Mood: [mood] ([reason])
|
||||
• Font: [font] ([reason])
|
||||
|
||||
[Then ask Question 6: Aspect Ratio]
|
||||
[Then ask Question 7: Aspect Ratio]
|
||||
```
|
||||
|
||||
## Confirmation Flow
|
||||
@@ -91,7 +92,26 @@ options:
|
||||
description: "Polished, precise, subtle gradients"
|
||||
```
|
||||
|
||||
### Q4: Other Settings (skip if all remaining dimensions already specified)
|
||||
### Q4: Font (skip if `--font`)
|
||||
|
||||
```yaml
|
||||
header: "Font"
|
||||
question: "Which font style?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "[auto-recommended font] (Recommended)"
|
||||
description: "[reason based on content signals]"
|
||||
- label: "clean"
|
||||
description: "Modern geometric sans-serif - tech, professional"
|
||||
- label: "handwritten"
|
||||
description: "Warm hand-lettered - personal, friendly"
|
||||
- label: "serif"
|
||||
description: "Classic elegant - editorial, luxury"
|
||||
- label: "display"
|
||||
description: "Bold decorative - announcements, entertainment"
|
||||
```
|
||||
|
||||
### Q5: Other Settings (skip if all remaining dimensions already specified)
|
||||
|
||||
Combine remaining settings into one question. Include: Output Dir (if no preference + file path input), Text, Mood, Aspect. Show auto-selected values as recommended option. User can accept all or type adjustments via "Other".
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Cover theme: [2-3 words visual interpretation]
|
||||
Type: [confirmed type]
|
||||
Palette: [confirmed palette]
|
||||
Rendering: [confirmed rendering]
|
||||
Font: [confirmed font]
|
||||
Text level: [confirmed text level]
|
||||
Mood: [confirmed mood]
|
||||
Aspect ratio: [confirmed ratio]
|
||||
@@ -44,6 +45,13 @@ Language: [confirmed language]
|
||||
- balanced: "Use medium contrast, normal saturation, balanced visual weight"
|
||||
- bold: "Use high contrast, vivid saturated colors, heavy visual weight, dynamic energy"
|
||||
|
||||
# Font Application
|
||||
[Based on font style:]
|
||||
- clean: "Use clean geometric sans-serif typography. Modern, minimal letterforms."
|
||||
- handwritten: "Use warm hand-lettered typography with organic brush strokes. Friendly, personal feel."
|
||||
- serif: "Use elegant serif typography with refined letterforms. Classic, editorial character."
|
||||
- display: "Use bold decorative display typography. Heavy, expressive headlines."
|
||||
|
||||
# Composition
|
||||
Type composition:
|
||||
- [Type-specific layout and structure]
|
||||
@@ -60,9 +68,15 @@ Palette notes: [key characteristics from palette definition]
|
||||
|
||||
[Watermark section if enabled]
|
||||
|
||||
[Reference images section if provided — see below]
|
||||
[Reference images section if provided — REQUIRED, see below]
|
||||
```
|
||||
|
||||
## Reference-Driven Design ⚠️ HIGH PRIORITY
|
||||
|
||||
When reference images are provided, they are the **primary visual input** and MUST strongly influence the output. The cover should look like it belongs to the same visual family as the references.
|
||||
|
||||
**Passing `--ref` alone is NOT enough.** Image generation models often ignore reference images unless the prompt text explicitly describes what to reproduce. Always combine `--ref` with detailed textual instructions.
|
||||
|
||||
## Content-Driven Design
|
||||
|
||||
- Article title and summary inform the visual metaphor choice
|
||||
@@ -167,63 +181,67 @@ For each reference image, extract:
|
||||
- **Color mood**: Palette characteristics (without specific colors)
|
||||
- **Elements**: Key visual elements and symbols used
|
||||
|
||||
### Step 2: Embed in Prompt
|
||||
### Step 2: Embed in Prompt ⚠️ CRITICAL
|
||||
|
||||
**If file saved AND skill supports `--ref`** (e.g., baoyu-image-gen with Google):
|
||||
- Pass ref images directly via `--ref` parameter
|
||||
- Add brief style note in prompt:
|
||||
**Passing `--ref` alone is NOT enough.** Image generation models frequently ignore reference images unless the prompt text explicitly and forcefully describes what to reproduce. You MUST always write detailed textual instructions regardless of whether `--ref` is used.
|
||||
|
||||
**If file saved (with or without `--ref` support)**:
|
||||
- Pass ref images via `--ref` parameter if skill supports it
|
||||
- **ALWAYS** add a detailed mandatory section in the prompt body:
|
||||
|
||||
```
|
||||
# Reference Style
|
||||
Follow the visual style of the attached reference image(s):
|
||||
- [1-2 sentence style summary from analysis]
|
||||
# Reference Style — MUST INCORPORATE
|
||||
|
||||
CRITICAL: The generated cover MUST visually reference the provided images. The cover must feel like it belongs to the same visual family.
|
||||
|
||||
## From Ref 1 ([filename]) — REQUIRED elements:
|
||||
- [Brand element]: [Specific description of logo/wordmark treatment, e.g., "The logo uses vertical parallel lines (|||) for the letter 'm'. Reproduce this exact treatment."]
|
||||
- [Signature pattern]: [Specific description, e.g., "Woven intersecting curves forming a diamond/lozenge grid pattern. This MUST appear prominently as a banner, border, or background section."]
|
||||
- [Colors]: [Exact hex values, e.g., "Dark teal #2D4A3E background, cream #F5F0E0 text"]
|
||||
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
|
||||
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
|
||||
|
||||
## From Ref 2 ([filename]) — REQUIRED elements:
|
||||
[Same detailed breakdown]
|
||||
|
||||
## Integration approach:
|
||||
[Specific layout instruction describing how reference elements combine with the cover content, e.g., "Use a SPLIT LAYOUT: main illustration area (warm cream background) occupies ~65% of the image, while a dark teal BANNER STRIP (with the woven line pattern from Ref 2) runs along the bottom ~35%, containing branding elements from Ref 1."]
|
||||
```
|
||||
|
||||
**If file saved BUT skill does NOT support `--ref`**:
|
||||
- Embed detailed text description in prompt:
|
||||
|
||||
```
|
||||
# Reference Style (Text Description)
|
||||
Emulate the following visual style from reference image(s):
|
||||
|
||||
Reference 1: [filename]
|
||||
- Style: [detailed rendering technique description]
|
||||
- Composition: [layout and hierarchy description]
|
||||
- Elements: [key visual elements used]
|
||||
- Mood: [emotional tone and visual weight]
|
||||
|
||||
[Repeat for additional references]
|
||||
|
||||
Apply these style characteristics to the cover design while maintaining the specified Type, Palette, and Rendering dimensions.
|
||||
```
|
||||
**Key rules**:
|
||||
- Each visual element gets its own bullet with "MUST" or "REQUIRED"
|
||||
- Descriptions must be **specific enough to reproduce** — not vague ("clean style")
|
||||
- The integration approach must describe **exact spatial arrangement**
|
||||
- After generation, verify reference elements are visible; if not, strengthen and regenerate
|
||||
|
||||
**If style/palette extracted verbally (NO file saved)**:
|
||||
- DO NOT add references metadata to prompt
|
||||
- Append extracted info directly to prompt body:
|
||||
- Append extracted info directly to prompt body using the same MUST INCORPORATE format above:
|
||||
|
||||
```
|
||||
# Extracted Style (from reference)
|
||||
# Reference Style — MUST INCORPORATE (extracted from visual analysis)
|
||||
|
||||
COLORS (from reference):
|
||||
- Primary: #E8756D coral
|
||||
- Secondary: #7ECFC0 mint
|
||||
...
|
||||
CRITICAL: Apply these specific visual elements extracted from the reference images.
|
||||
|
||||
STYLE (from reference):
|
||||
- Clean lines, minimal shadows
|
||||
- Gradient backgrounds
|
||||
- Flat vector icons
|
||||
...
|
||||
## REQUIRED elements:
|
||||
- [Same detailed bullet format as above]
|
||||
|
||||
## Integration approach:
|
||||
[Same spatial layout instruction]
|
||||
```
|
||||
|
||||
### Reference Analysis Template
|
||||
|
||||
Use this format when analyzing reference images:
|
||||
Use this format when analyzing reference images. Extract **specific, concrete, reproducible** details — not vague summaries.
|
||||
|
||||
| Aspect | Analysis Points |
|
||||
|--------|-----------------|
|
||||
| **Rendering** | Line quality (clean/sketchy/painted), texture presence, depth treatment |
|
||||
| **Composition** | Element placement, whitespace ratio, visual flow |
|
||||
| **Typography** | Font style, text placement, hierarchy (if present) |
|
||||
| **Elements** | Icon vocabulary, decorative motifs, character style |
|
||||
| **Mood** | Contrast level, saturation, visual weight |
|
||||
| Aspect | Analysis Points | Good Example | Bad Example |
|
||||
|--------|-----------------|--------------|-------------|
|
||||
| **Brand elements** | Logos, wordmarks, distinctive typography | "Logo 'm' formed by 3 vertical lines" | "Has a logo" |
|
||||
| **Signature patterns** | Unique motifs, textures, geometric patterns | "Woven curves forming diamond grid" | "Has patterns" |
|
||||
| **Colors** | Exact hex values or close approximations | "#2D4A3E dark teal, #F5F0E0 cream" | "Dark and light" |
|
||||
| **Layout** | Spatial zones, banner placement, proportions | "Bottom 30% is dark banner with branding" | "Has a banner" |
|
||||
| **Typography** | Font style, weight, case, spacing, position | "Uppercase, wide letter-spacing, right-aligned" | "Has text" |
|
||||
| **Rendering** | Line quality, texture, depth treatment | "Topographic contour lines as background texture" | "Clean style" |
|
||||
| **Elements** | Icon vocabulary, decorative motifs | "Geometric intersecting line ornaments at corners" | "Has decorations" |
|
||||
|
||||
**Output**: Each extracted element should be written as a **copy-pasteable prompt instruction** prefixed with "MUST" or "REQUIRED".
|
||||
|
||||
@@ -41,7 +41,9 @@ test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||
│ Not found │ Use defaults │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
|
||||
|
||||
Schema: `references/config/preferences-schema.md`
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -58,9 +60,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --quali
|
||||
# From prompt files
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# With reference images (Google multimodal only)
|
||||
# With reference images (Google multimodal or OpenAI edits)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||
|
||||
# With reference images (explicit provider/model)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
||||
|
||||
# Specific provider
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||
|
||||
@@ -76,12 +81,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||
| `--image <path>` | Output image path (required) |
|
||||
| `--provider google\|openai\|dashscope` | Force provider (default: google) |
|
||||
| `--model <id>`, `-m` | Model ID |
|
||||
| `--model <id>`, `-m` | Model ID (`--ref` with OpenAI requires GPT Image model, e.g. `gpt-image-1.5`) |
|
||||
| `--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 (default: from quality) |
|
||||
| `--ref <files...>` | Reference images (Google multimodal only) |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
|
||||
| `--n <count>` | Number of images |
|
||||
| `--json` | JSON output |
|
||||
|
||||
@@ -99,13 +104,14 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image ou
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||
|
||||
**Load Priority**: CLI args > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
## Provider Selection
|
||||
|
||||
1. `--provider` specified → use it
|
||||
2. Only one API key available → use that provider
|
||||
3. Multiple available → default to Google
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI
|
||||
2. `--provider` specified → use it (if `--ref`, must be `google` or `openai`)
|
||||
3. Only one API key available → use that provider
|
||||
4. Multiple available → default to Google
|
||||
|
||||
## Quality Presets
|
||||
|
||||
@@ -155,7 +161,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
- Missing API key → error with setup instructions
|
||||
- Generation failure → auto-retry once
|
||||
- Invalid aspect ratio → warning, proceed with default
|
||||
- Reference images with non-multimodal model → warning, ignore refs
|
||||
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal or OpenAI GPT Image edits)
|
||||
|
||||
## Extension Support
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: preferences-schema
|
||||
description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
||||
---
|
||||
|
||||
# Preferences Schema
|
||||
|
||||
## Full Schema
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
|
||||
default_provider: null # google|openai|dashscope|null (null = auto-detect)
|
||||
|
||||
default_quality: null # normal|2k|null (null = use default: 2k)
|
||||
|
||||
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 only, overrides quality)
|
||||
|
||||
default_model:
|
||||
google: null # e.g., "gemini-3-pro-image-preview"
|
||||
openai: null # e.g., "gpt-image-1.5"
|
||||
dashscope: null # e.g., "z-image-turbo"
|
||||
---
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `version` | int | 1 | Schema version |
|
||||
| `default_provider` | string\|null | null | Default provider (null = auto-detect) |
|
||||
| `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 image size (overrides quality) |
|
||||
| `default_model.google` | string\|null | null | Google default model |
|
||||
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||
|
||||
## Examples
|
||||
|
||||
**Minimal**:
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
---
|
||||
```
|
||||
|
||||
**Full**:
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
default_aspect_ratio: "16:9"
|
||||
default_image_size: 2K
|
||||
default_model:
|
||||
google: "gemini-3-pro-image-preview"
|
||||
openai: "gpt-image-1.5"
|
||||
dashscope: "z-image-turbo"
|
||||
---
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type { CliArgs, Provider } from "./types";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import type { CliArgs, Provider, ExtendConfig } from "./types";
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Usage:
|
||||
@@ -20,7 +20,7 @@ Options:
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
--quality normal|2k Quality preset (default: 2k)
|
||||
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
||||
--ref <files...> Reference images (Google multimodal only)
|
||||
--ref <files...> Reference images (Google multimodal or OpenAI edits)
|
||||
--n <count> Number of images (default: 1)
|
||||
--json JSON output
|
||||
-h, --help Show help
|
||||
@@ -37,7 +37,7 @@ Environment variables:
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
|
||||
Env file load order: CLI args > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
@@ -49,7 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: "2k",
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
@@ -215,6 +215,87 @@ async function loadEnv(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function extractYamlFrontMatter(content: string): string | null {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
const config: Partial<ExtendConfig> = {};
|
||||
const lines = yaml.split("\n");
|
||||
let currentKey: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
let value = trimmed.slice(colonIdx + 1).trim();
|
||||
|
||||
if (value === "null" || value === "") {
|
||||
value = "null";
|
||||
}
|
||||
|
||||
if (key === "version") {
|
||||
config.version = value === "null" ? 1 : parseInt(value, 10);
|
||||
} else if (key === "default_provider") {
|
||||
config.default_provider = value === "null" ? null : (value as Provider);
|
||||
} else if (key === "default_quality") {
|
||||
config.default_quality = value === "null" ? null : (value as "normal" | "2k");
|
||||
} else if (key === "default_aspect_ratio") {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
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_model") {
|
||||
config.default_model = { google: null, openai: null, dashscope: null };
|
||||
currentKey = "default_model";
|
||||
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope")) {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
config.default_model![key] = cleaned === "null" ? null : cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
||||
const home = homedir();
|
||||
const cwd = process.cwd();
|
||||
|
||||
const paths = [
|
||||
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const content = await readFile(p, "utf8");
|
||||
const yaml = extractYamlFrontMatter(content);
|
||||
if (!yaml) continue;
|
||||
|
||||
return parseSimpleYaml(yaml);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
for (const f of files) {
|
||||
@@ -242,12 +323,26 @@ function normalizeOutputImagePath(p: string): string {
|
||||
}
|
||||
|
||||
function detectProvider(args: CliArgs): Provider {
|
||||
if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai") {
|
||||
throw new Error(
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal) or --provider openai (GPT Image edits)."
|
||||
);
|
||||
}
|
||||
|
||||
if (args.provider) return args.provider;
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
if (hasOpenai) return "openai";
|
||||
throw new Error(
|
||||
"Reference images require Google or OpenAI. Set GOOGLE_API_KEY/GEMINI_API_KEY or OPENAI_API_KEY, or remove --ref."
|
||||
);
|
||||
}
|
||||
|
||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope"].filter(Boolean) as Provider[];
|
||||
|
||||
if (available.length === 1) return available[0]!;
|
||||
@@ -259,11 +354,34 @@ function detectProvider(args: CliArgs): Provider {
|
||||
);
|
||||
}
|
||||
|
||||
async function validateReferenceImages(referenceImages: string[]): Promise<void> {
|
||||
for (const refPath of referenceImages) {
|
||||
const fullPath = path.resolve(refPath);
|
||||
try {
|
||||
await access(fullPath);
|
||||
} catch {
|
||||
throw new Error(`Reference image not found: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderModule = {
|
||||
getDefaultModel: () => string;
|
||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
||||
};
|
||||
|
||||
function isRetryableGenerationError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const nonRetryableMarkers = [
|
||||
"Reference image",
|
||||
"not supported",
|
||||
"only supported",
|
||||
"No API key found",
|
||||
"is required",
|
||||
];
|
||||
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
||||
}
|
||||
|
||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "google") {
|
||||
return (await import("./providers/google")) as ProviderModule;
|
||||
@@ -283,9 +401,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await loadEnv();
|
||||
const extendConfig = await loadExtendConfig();
|
||||
const mergedArgs = mergeConfig(args, extendConfig);
|
||||
|
||||
let prompt: string | null = args.prompt;
|
||||
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
|
||||
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
||||
|
||||
let prompt: string | null = mergedArgs.prompt;
|
||||
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
|
||||
if (!prompt) prompt = await readPromptFromStdin();
|
||||
|
||||
if (!prompt) {
|
||||
@@ -295,27 +417,39 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.imagePath) {
|
||||
if (!mergedArgs.imagePath) {
|
||||
console.error("Error: --image is required");
|
||||
printUsage();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = detectProvider(args);
|
||||
if (mergedArgs.referenceImages.length > 0) {
|
||||
await validateReferenceImages(mergedArgs.referenceImages);
|
||||
}
|
||||
|
||||
const provider = detectProvider(mergedArgs);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
const model = args.model || providerModule.getDefaultModel();
|
||||
const outputPath = normalizeOutputImagePath(args.imagePath);
|
||||
|
||||
let model = mergedArgs.model;
|
||||
if (!model && extendConfig.default_model) {
|
||||
if (provider === "google") model = extendConfig.default_model.google ?? null;
|
||||
if (provider === "openai") model = extendConfig.default_model.openai ?? null;
|
||||
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
|
||||
}
|
||||
model = model || providerModule.getDefaultModel();
|
||||
|
||||
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
|
||||
|
||||
let imageData: Uint8Array;
|
||||
let retried = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
imageData = await providerModule.generateImage(prompt, model, args);
|
||||
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
|
||||
break;
|
||||
} catch (e) {
|
||||
if (!retried) {
|
||||
if (!retried && isRetryableGenerationError(e)) {
|
||||
retried = true;
|
||||
console.error("Generation failed, retrying...");
|
||||
continue;
|
||||
@@ -328,7 +462,7 @@ async function main(): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(outputPath, imageData);
|
||||
|
||||
if (args.json) {
|
||||
if (mergedArgs.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
|
||||
@@ -58,7 +58,9 @@ export async function generateImage(
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not yet supported with DashScope, ignoring.");
|
||||
throw new Error(
|
||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||
);
|
||||
}
|
||||
|
||||
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
|
||||
@@ -216,13 +216,17 @@ export async function generateImage(
|
||||
): Promise<Uint8Array> {
|
||||
if (isGoogleImagen(model)) {
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not supported with Imagen models, ignoring.");
|
||||
throw new Error(
|
||||
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
|
||||
);
|
||||
}
|
||||
return generateWithImagen(prompt, model, args);
|
||||
}
|
||||
|
||||
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images are only supported with Gemini multimodal models.");
|
||||
throw new Error(
|
||||
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
|
||||
);
|
||||
}
|
||||
|
||||
return generateWithGemini(prompt, model, args);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
}
|
||||
|
||||
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
@@ -66,20 +70,32 @@ export async function generateImage(
|
||||
|
||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
console.error("Warning: Reference images not supported with OpenAI, ignoring.");
|
||||
}
|
||||
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
|
||||
const body: Record<string, any> = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
};
|
||||
if (args.referenceImages.length > 0) {
|
||||
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)."
|
||||
);
|
||||
}
|
||||
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
|
||||
}
|
||||
|
||||
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
||||
}
|
||||
|
||||
async function generateWithOpenAIGenerations(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
quality: CliArgs["quality"]
|
||||
): Promise<Uint8Array> {
|
||||
const body: Record<string, any> = { model, prompt, size };
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = args.quality === "2k" ? "hd" : "standard";
|
||||
body.quality = quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/generations`, {
|
||||
@@ -96,7 +112,62 @@ export async function generateImage(
|
||||
throw new Error(`OpenAI API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as { data: Array<{ url?: string; b64_json?: string }> };
|
||||
const result = (await res.json()) as OpenAIImageResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
async function generateWithOpenAIEdits(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
referenceImages: string[],
|
||||
quality: CliArgs["quality"]
|
||||
): Promise<Uint8Array> {
|
||||
const form = new FormData();
|
||||
form.append("model", model);
|
||||
form.append("prompt", prompt);
|
||||
form.append("size", size);
|
||||
|
||||
if (model.includes("gpt-image")) {
|
||||
form.append("quality", quality === "2k" ? "high" : "medium");
|
||||
}
|
||||
|
||||
for (const refPath of referenceImages) {
|
||||
const bytes = await readFile(refPath);
|
||||
const filename = path.basename(refPath);
|
||||
const mimeType = getMimeType(filename);
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
form.append("image[]", blob, filename);
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/edits`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`OpenAI edits API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as OpenAIImageResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {
|
||||
const img = result.data[0];
|
||||
|
||||
if (img?.b64_json) {
|
||||
|
||||
@@ -9,10 +9,23 @@ export type CliArgs = {
|
||||
model: string | null;
|
||||
aspectRatio: string | null;
|
||||
size: string | null;
|
||||
quality: Quality;
|
||||
quality: Quality | null;
|
||||
imageSize: string | null;
|
||||
referenceImages: string[];
|
||||
n: number;
|
||||
json: boolean;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
export type ExtendConfig = {
|
||||
version: number;
|
||||
default_provider: Provider | null;
|
||||
default_quality: Quality | null;
|
||||
default_aspect_ratio: string | null;
|
||||
default_image_size: "1K" | "2K" | "4K" | null;
|
||||
default_model: {
|
||||
google: string | null;
|
||||
openai: string | null;
|
||||
dashscope: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -126,7 +126,18 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
const stripQuotes = (s?: string): string => {
|
||||
if (!s) return '';
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
let title = options?.title ?? stripQuotes(frontmatter.title) ?? '';
|
||||
if (!title) {
|
||||
const lines = body.split('\n');
|
||||
for (const line of lines) {
|
||||
@@ -138,8 +149,8 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
}
|
||||
}
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
const author = frontmatter.author || '';
|
||||
let summary = frontmatter.description || frontmatter.summary || '';
|
||||
const author = stripQuotes(frontmatter.author);
|
||||
let summary = stripQuotes(frontmatter.description) || stripQuotes(frontmatter.summary);
|
||||
|
||||
if (!summary) {
|
||||
const lines = body.split('\n');
|
||||
|
||||
@@ -708,14 +708,28 @@ function normalizeThemeCss(css: string): string {
|
||||
return stripOutputScope(css);
|
||||
}
|
||||
|
||||
function buildHtmlDocument(title: string, css: string, html: string): string {
|
||||
return [
|
||||
interface HtmlDocumentMeta {
|
||||
title: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string): string {
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${title}</title>`,
|
||||
` <title>${meta.title}</title>`,
|
||||
];
|
||||
if (meta.author) {
|
||||
lines.push(` <meta name="author" content="${meta.author}" />`);
|
||||
}
|
||||
if (meta.description) {
|
||||
lines.push(` <meta name="description" content="${meta.description}" />`);
|
||||
}
|
||||
lines.push(
|
||||
` <style>${css}</style>`,
|
||||
"</head>",
|
||||
"<body>",
|
||||
@@ -723,8 +737,9 @@ function buildHtmlDocument(title: string, css: string, html: string): string {
|
||||
html,
|
||||
" </div>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
"</html>"
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function inlineCss(html: string): Promise<string> {
|
||||
@@ -814,6 +829,7 @@ async function main(): Promise<void> {
|
||||
const markdown = fs.readFileSync(inputPath, "utf-8");
|
||||
|
||||
const renderer = initRenderer({});
|
||||
const { yamlData } = renderer.parseFrontMatterAndContent(markdown);
|
||||
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
|
||||
markdown,
|
||||
renderer
|
||||
@@ -823,8 +839,23 @@ async function main(): Promise<void> {
|
||||
content = removeFirstHeading(content);
|
||||
}
|
||||
|
||||
const title = path.basename(outputPath, ".html");
|
||||
const html = buildHtmlDocument(title, css, content);
|
||||
const stripQuotes = (s?: string): string | undefined => {
|
||||
if (!s) return s;
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const meta: HtmlDocumentMeta = {
|
||||
title: stripQuotes(yamlData.title) || path.basename(outputPath, ".html"),
|
||||
author: stripQuotes(yamlData.author),
|
||||
description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
|
||||
};
|
||||
const html = buildHtmlDocument(meta, css, content);
|
||||
const inlinedHtml = normalizeInlineCss(await inlineCss(html));
|
||||
const finalHtml = modifyHtmlStructure(inlinedHtml);
|
||||
|
||||
|
||||
@@ -120,22 +120,28 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
let title = options?.title ?? frontmatter.title ?? '';
|
||||
let bodyWithoutTitle = body;
|
||||
if (!title) {
|
||||
const lines = body.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
|
||||
if (headingMatch) title = headingMatch[1]!;
|
||||
if (headingMatch) {
|
||||
title = headingMatch[1]!;
|
||||
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
bodyWithoutTitle = body.replace(/^#{1,2}\s+.+\r?\n?/, '');
|
||||
}
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
const author = frontmatter.author || '';
|
||||
let summary = frontmatter.description || frontmatter.summary || '';
|
||||
|
||||
if (!summary) {
|
||||
const lines = body.split('\n');
|
||||
const lines = bodyWithoutTitle.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
@@ -161,7 +167,7 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
const images: Array<{ src: string; placeholder: string }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const modifiedBody = bodyWithoutTitle.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||||
const placeholder = `WECHATIMGPH_${++imageCounter}`;
|
||||
images.push({ src, placeholder });
|
||||
return placeholder;
|
||||
|
||||
@@ -374,7 +374,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
|
||||
listCounters[listCounters.length - 1] = idx + 1;
|
||||
|
||||
const prefix = ordered ? `${idx}. ` : "• ";
|
||||
const prefix = ordered ? "" : "• ";
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
|
||||
@@ -273,22 +273,31 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
|
||||
const placeholder = ${JSON.stringify(placeholder)};
|
||||
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || '';
|
||||
const idx = text.indexOf(${JSON.stringify(placeholder)});
|
||||
if (idx !== -1) {
|
||||
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
let searchStart = 0;
|
||||
let idx;
|
||||
// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)
|
||||
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
|
||||
const afterIdx = idx + placeholder.length;
|
||||
const charAfter = text[afterIdx];
|
||||
// Exact match if next char is not a digit
|
||||
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
||||
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + ${placeholder.length});
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + placeholder.length);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
}
|
||||
searchStart = afterIdx;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -306,6 +315,71 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
|
||||
const removed = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
if (!editor) return false;
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return false;
|
||||
|
||||
let node = sel.anchorNode;
|
||||
if (!node) return false;
|
||||
let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
if (!element || !editor.contains(element)) return false;
|
||||
|
||||
const isEmptyParagraph = (el) => {
|
||||
if (!el || el.tagName !== 'P') return false;
|
||||
const text = (el.textContent || '').trim();
|
||||
if (text.length > 0) return false;
|
||||
return el.querySelectorAll('img, figure, video, iframe').length === 0;
|
||||
};
|
||||
|
||||
const hasImage = (el) => {
|
||||
if (!el) return false;
|
||||
return !!el.querySelector('img, figure img, picture img');
|
||||
};
|
||||
|
||||
const placeCursorAfter = (el) => {
|
||||
if (!el) return;
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(el);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
};
|
||||
|
||||
// Case 1: caret is inside an empty paragraph right after an image block.
|
||||
const emptyPara = element.closest('p');
|
||||
if (emptyPara && editor.contains(emptyPara) && isEmptyParagraph(emptyPara)) {
|
||||
const prev = emptyPara.previousElementSibling;
|
||||
if (prev && hasImage(prev)) {
|
||||
emptyPara.remove();
|
||||
placeCursorAfter(prev);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: caret is on the image block itself; remove the next empty paragraph.
|
||||
const imageBlock = element.closest('figure, p');
|
||||
if (imageBlock && editor.contains(imageBlock) && hasImage(imageBlock)) {
|
||||
const next = imageBlock.nextElementSibling;
|
||||
if (next && isEmptyParagraph(next)) {
|
||||
next.remove();
|
||||
placeCursorAfter(imageBlock);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (removed) console.log('[wechat] Removed extra empty line after image.');
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
||||
let { contentImages = [] } = options;
|
||||
@@ -445,11 +519,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
console.log(`[wechat] Filling summary: ${effectiveSummary}`);
|
||||
await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
if (effectiveTitle) {
|
||||
@@ -461,15 +530,6 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
|
||||
if (actualSummary === effectiveSummary) {
|
||||
console.log('[wechat] Summary verified OK.');
|
||||
} else {
|
||||
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Clicking on editor...');
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await sleep(1000);
|
||||
@@ -525,6 +585,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log('[wechat] Pasting image...');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
console.log('[wechat] All images inserted.');
|
||||
}
|
||||
@@ -536,6 +597,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await sleep(500);
|
||||
await pasteInEditor(session);
|
||||
await sleep(2000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +620,30 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSummary) {
|
||||
console.log(`[wechat] Filling summary (after content paste): ${effectiveSummary}`);
|
||||
await evaluate(session, `
|
||||
(function() {
|
||||
const el = document.querySelector('#js_description');
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.select();
|
||||
el.value = ${JSON.stringify(effectiveSummary)};
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
})()
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const actualSummary = await evaluate<string>(session, `document.querySelector('#js_description')?.value || ''`);
|
||||
if (actualSummary === effectiveSummary) {
|
||||
console.log('[wechat] Summary verified OK.');
|
||||
} else {
|
||||
console.warn(`[wechat] Summary verification failed. Expected: "${effectiveSummary}", got: "${actualSummary}"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[wechat] Saving as draft...');
|
||||
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
||||
await sleep(3000);
|
||||
|
||||
@@ -61,6 +61,25 @@ interface ArticleOptions {
|
||||
chromePath?: string;
|
||||
}
|
||||
|
||||
async function findExistingDebugPort(profileDir: string): Promise<number | null> {
|
||||
const portFile = path.join(profileDir, 'DevToolsActivePort');
|
||||
if (!fs.existsSync(portFile)) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, 'utf-8').trim();
|
||||
if (!content) return null;
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number(portLine);
|
||||
if (!Number.isFinite(port) || port <= 0) return null;
|
||||
|
||||
// Verify the port is actually active.
|
||||
await waitForChromeDebugPort(port, 1500, { includeLastError: true });
|
||||
return port;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
|
||||
|
||||
@@ -83,28 +102,33 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
if (!chromePath) throw new Error('Chrome not found');
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
const port = await getFreePort();
|
||||
const existingPort = await findExistingDebugPort(profileDir);
|
||||
const port = existingPort ?? await getFreePort();
|
||||
|
||||
console.log(`[x-article] Launching Chrome...`);
|
||||
const chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
X_ARTICLES_URL,
|
||||
], { stdio: 'ignore' });
|
||||
if (existingPort) {
|
||||
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
|
||||
} else {
|
||||
console.log(`[x-article] Launching Chrome...`);
|
||||
spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
X_ARTICLES_URL,
|
||||
], { stdio: 'ignore' });
|
||||
}
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
|
||||
try {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
|
||||
|
||||
// Get page target
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.startsWith(X_ARTICLES_URL));
|
||||
|
||||
if (!pageTarget) {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
|
||||
@@ -409,15 +433,23 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
|
||||
console.log('[x-article] Checking for placeholders in content...');
|
||||
for (const img of parsed.contentImages) {
|
||||
if (editorContent.result.value.includes(img.placeholder)) {
|
||||
// Use regex for exact match (not followed by digit, e.g., XIMGPH_1 should not match XIMGPH_10)
|
||||
const regex = new RegExp(img.placeholder + '(?!\\d)');
|
||||
if (regex.test(editorContent.result.value)) {
|
||||
console.log(`[x-article] Found: ${img.placeholder}`);
|
||||
} else {
|
||||
console.log(`[x-article] NOT found: ${img.placeholder}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process images in sequential order (1, 2, 3, ...)
|
||||
const sortedImages = [...parsed.contentImages].sort((a, b) => a.blockIndex - b.blockIndex);
|
||||
// Process images in XIMGPH order (1, 2, 3, ...) regardless of blockIndex
|
||||
const getPlaceholderIndex = (placeholder: string): number => {
|
||||
const match = placeholder.match(/XIMGPH_(\d+)/);
|
||||
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
const sortedImages = [...parsed.contentImages].sort(
|
||||
(a, b) => getPlaceholderIndex(a.placeholder) - getPlaceholderIndex(b.placeholder),
|
||||
);
|
||||
|
||||
for (let i = 0; i < sortedImages.length; i++) {
|
||||
const img = sortedImages[i]!;
|
||||
@@ -440,22 +472,30 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || '';
|
||||
const idx = text.indexOf(placeholder);
|
||||
if (idx !== -1) {
|
||||
// Found the placeholder - scroll to it first
|
||||
const parentElement = node.parentElement;
|
||||
if (parentElement) {
|
||||
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
let searchStart = 0;
|
||||
let idx;
|
||||
// Search for exact match (not prefix of longer placeholder like XIMGPH_1 in XIMGPH_10)
|
||||
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
|
||||
const afterIdx = idx + placeholder.length;
|
||||
const charAfter = text[afterIdx];
|
||||
// Exact match if next char is not a digit (XIMGPH_1 should not match XIMGPH_10)
|
||||
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
||||
// Found exact placeholder - scroll to it first
|
||||
const parentElement = node.parentElement;
|
||||
if (parentElement) {
|
||||
parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Select it
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + placeholder.length);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
// Select it
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + placeholder.length);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return true;
|
||||
}
|
||||
searchStart = afterIdx;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -505,31 +545,54 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
// Wait for clipboard to be fully ready
|
||||
await sleep(1000);
|
||||
|
||||
// Delete placeholder by pressing Backspace (more reliable than Enter for replacing selection)
|
||||
// Delete placeholder using execCommand (more reliable than keyboard events for DraftJS)
|
||||
console.log(`[x-article] Deleting placeholder...`);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
const deleteResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
// Try execCommand delete first
|
||||
if (document.execCommand('delete', false)) return true;
|
||||
// Fallback: replace selection with empty using insertText
|
||||
document.execCommand('insertText', false, '');
|
||||
return true;
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
// Wait and verify placeholder is deleted
|
||||
await sleep(500);
|
||||
|
||||
// Check that placeholder is no longer in editor
|
||||
// Check that placeholder is no longer in editor (exact match, not substring)
|
||||
const afterDelete = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const editor = document.querySelector('.DraftEditor-editorContainer [data-contents="true"]');
|
||||
if (!editor) return true;
|
||||
return !editor.innerText.includes(${JSON.stringify(img.placeholder)});
|
||||
const text = editor.innerText;
|
||||
const placeholder = ${JSON.stringify(img.placeholder)};
|
||||
// Use regex to find exact match (not followed by digit)
|
||||
const regex = new RegExp(placeholder + '(?!\\\\d)');
|
||||
return !regex.test(text);
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
}, { sessionId });
|
||||
|
||||
if (!afterDelete.result.value) {
|
||||
console.warn(`[x-article] Placeholder may not have been deleted, trying again...`);
|
||||
// Try selecting and deleting again
|
||||
console.warn(`[x-article] Placeholder may not have been deleted, trying dispatchEvent...`);
|
||||
// Try selecting and deleting with InputEvent
|
||||
await selectPlaceholder(1);
|
||||
await sleep(300);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const editor = document.querySelector('.DraftEditor-editorContainer [contenteditable="true"]');
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
// Dispatch beforeinput and input events for deletion
|
||||
const beforeEvent = new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true });
|
||||
editor.dispatchEvent(beforeEvent);
|
||||
const inputEvent = new InputEvent('input', { inputType: 'deleteContentBackward', bubbles: true });
|
||||
editor.dispatchEvent(inputEvent);
|
||||
})()`,
|
||||
}, { sessionId });
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user