mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-16 15:39:47 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13707fa2cf | |||
| b56e503b16 | |||
| 7d03685ade | |||
| b305c386bc | |||
| 240dd7d314 | |||
| b02ceacfd9 | |||
| fdf9007e2c | |||
| 08cee885d3 | |||
| 3bd5fdeb1b | |||
| e737c4a611 | |||
| 2eec4f3639 | |||
| e5912018f3 | |||
| b1f568d03d |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.35.0"
|
"version": "1.38.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.38.0 - 2026-02-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-danger-x-to-markdown`: render embedded tweets in articles as blockquotes with author info and text summary
|
||||||
|
- `baoyu-danger-x-to-markdown`: reuse existing markdown when `--download-media` targets already-converted URLs
|
||||||
|
- `baoyu-danger-x-to-markdown`: upgrade Twitter image downloads to 4096x4096 high resolution
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-danger-x-to-markdown`: improve entity resolution with logical key lookup for reliable media and link mapping
|
||||||
|
- `baoyu-danger-x-to-markdown`: support trailing media for all block types (headings, lists, blockquotes)
|
||||||
|
|
||||||
|
## 1.37.1 - 2026-02-27
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-danger-gemini-web`: sync model headers with upstream and update model list (by @xkcoding)
|
||||||
|
|
||||||
|
## 1.37.0 - 2026-02-27
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-danger-x-to-markdown`: add inline link rendering for X article content, mapping LINK/MEDIA entities to markdown links
|
||||||
|
- `baoyu-danger-x-to-markdown`: use content-based slug in output directory path for meaningful folder names
|
||||||
|
- `baoyu-danger-x-to-markdown`: add atomic media queue for blocks without direct media references
|
||||||
|
|
||||||
|
## 1.36.0 - 2026-02-27
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-image-gen`: add `gemini-3.1-flash-image-preview` model support for Google multimodal image generation
|
||||||
|
- `baoyu-image-gen`: improve first-time setup with blocking preferences flow and guided configuration
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-image-gen`: use curl fallback for Google API when HTTP proxy is detected (by @liye71023326)
|
||||||
|
|
||||||
## 1.35.0 - 2026-02-24
|
## 1.35.0 - 2026-02-24
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.38.0 - 2026-02-28
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-danger-x-to-markdown`:支持文章内嵌推文渲染,以引用块形式显示作者信息和推文摘要
|
||||||
|
- `baoyu-danger-x-to-markdown`:`--download-media` 复用已转换的 Markdown 文件,跳过重复抓取
|
||||||
|
- `baoyu-danger-x-to-markdown`:推特图片下载升级至 4096x4096 高分辨率
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-danger-x-to-markdown`:改进实体解析逻辑,通过逻辑键查找提升媒体和链接映射准确性
|
||||||
|
- `baoyu-danger-x-to-markdown`:所有区块类型(标题、列表、引用块)支持尾随媒体展示
|
||||||
|
|
||||||
|
## 1.37.1 - 2026-02-27
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-danger-gemini-web`:同步上游模型请求头并更新模型列表 (by @xkcoding)
|
||||||
|
|
||||||
|
## 1.37.0 - 2026-02-27
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-danger-x-to-markdown`:支持 X 文章内联链接渲染,将 LINK/MEDIA 实体映射为 Markdown 链接
|
||||||
|
- `baoyu-danger-x-to-markdown`:输出目录使用基于内容的 slug,生成更有意义的文件夹名称
|
||||||
|
- `baoyu-danger-x-to-markdown`:新增 atomic 媒体队列,支持无直接媒体引用的区块
|
||||||
|
|
||||||
|
## 1.36.0 - 2026-02-27
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-image-gen`:新增 `gemini-3.1-flash-image-preview` Google 多模态图片生成模型支持
|
||||||
|
- `baoyu-image-gen`:优化首次使用引导流程,支持阻塞式偏好配置
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-image-gen`:检测到 HTTP 代理时自动回退使用 curl 调用 Google API (by @liye71023326)
|
||||||
|
|
||||||
## 1.35.0 - 2026-02-24
|
## 1.35.0 - 2026-02-24
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
|
|||||||
```bash
|
```bash
|
||||||
# Text generation
|
# Text generation
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt"
|
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt"
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-2.5-pro
|
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-3-flash
|
||||||
|
|
||||||
# Image generation
|
# Image generation
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
|
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||||
@@ -100,7 +100,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--prompt`, `-p` | Prompt text |
|
| `--prompt`, `-p` | Prompt text |
|
||||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||||
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
|
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-3-flash, gemini-3-flash-thinking, gemini-3.1-pro-preview |
|
||||||
| `--image [path]` | Generate image (default: generated.png) |
|
| `--image [path]` | Generate image (default: generated.png) |
|
||||||
| `--reference`, `--ref` | Reference images for vision input |
|
| `--reference`, `--ref` | Reference images for vision input |
|
||||||
| `--sessionId` | Session ID for multi-turn conversation |
|
| `--sessionId` | Session ID for multi-turn conversation |
|
||||||
@@ -114,9 +114,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
|||||||
|
|
||||||
| Model | Description |
|
| Model | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `gemini-3-pro` | Default, latest |
|
| `gemini-3-pro` | Default, latest 3.0 Pro |
|
||||||
| `gemini-2.5-pro` | Previous pro |
|
| `gemini-3-flash` | Fast, lightweight 3.0 Flash |
|
||||||
| `gemini-2.5-flash` | Fast, lightweight |
|
| `gemini-3-flash-thinking` | 3.0 Flash with thinking |
|
||||||
|
| `gemini-3.1-pro-preview` | 3.1 Pro preview (empty header, auto-routed) |
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -47,17 +47,22 @@ export class Model {
|
|||||||
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
|
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
|
||||||
static readonly G_3_0_PRO = new Model(
|
static readonly G_3_0_PRO = new Model(
|
||||||
'gemini-3.0-pro',
|
'gemini-3.0-pro',
|
||||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' },
|
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' },
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
static readonly G_2_5_PRO = new Model(
|
static readonly G_3_0_FLASH = new Model(
|
||||||
'gemini-2.5-pro',
|
'gemini-3.0-flash',
|
||||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
|
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4],null,null,1]' },
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
static readonly G_2_5_FLASH = new Model(
|
static readonly G_3_0_FLASH_THINKING = new Model(
|
||||||
'gemini-2.5-flash',
|
'gemini-3.0-flash-thinking',
|
||||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
|
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"5bf011840784117a",null,null,0,[4],null,null,1]' },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
static readonly G_3_1_PRO_PREVIEW = new Model(
|
||||||
|
'gemini-3.1-pro-preview',
|
||||||
|
{},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,12 +73,12 @@ export class Model {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
static from_name(name: string): Model {
|
static from_name(name: string): Model {
|
||||||
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) {
|
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]) {
|
||||||
if (model.model_name === name) return model;
|
if (model.model_name === name) return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]
|
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]
|
||||||
.map((m) => m.model_name)
|
.map((m) => m.model_name)
|
||||||
.join(', ')}`,
|
.join(', ')}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Multi-turn conversation (agent generates unique sessionId):
|
|||||||
Options:
|
Options:
|
||||||
-p, --prompt <text> Prompt text
|
-p, --prompt <text> Prompt text
|
||||||
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
|
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
|
||||||
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
|
-m, --model <id> gemini-3-pro | gemini-3-flash | gemini-3-flash-thinking | gemini-3.1-pro-preview (default: gemini-3-pro)
|
||||||
--json Output JSON
|
--json Output JSON
|
||||||
--image [path] Generate an image and save it (default: ./generated.png)
|
--image [path] Generate an image and save it (default: ./generated.png)
|
||||||
--reference <files...> Reference images for vision input
|
--reference <files...> Reference images for vision input
|
||||||
@@ -227,8 +227,11 @@ function resolveModel(id: string): Model {
|
|||||||
const k = id.trim();
|
const k = id.trim();
|
||||||
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
|
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
|
||||||
if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO;
|
if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO;
|
||||||
if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO;
|
if (k === 'gemini-3-flash') return Model.G_3_0_FLASH;
|
||||||
if (k === 'gemini-2.5-flash') return Model.G_2_5_FLASH;
|
if (k === 'gemini-3.0-flash') return Model.G_3_0_FLASH;
|
||||||
|
if (k === 'gemini-3-flash-thinking') return Model.G_3_0_FLASH_THINKING;
|
||||||
|
if (k === 'gemini-3.0-flash-thinking') return Model.G_3_0_FLASH_THINKING;
|
||||||
|
if (k === 'gemini-3.1-pro-preview') return Model.G_3_1_PRO_PREVIEW;
|
||||||
return Model.from_name(k);
|
return Model.from_name(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ coverImage: "https://pbs.twimg.com/media/example.jpg"
|
|||||||
Content...
|
Content...
|
||||||
```
|
```
|
||||||
|
|
||||||
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
|
**File structure**: `x-to-markdown/{username}/{tweet-id}/{content-slug}.md`
|
||||||
|
|
||||||
When `--download-media` is enabled:
|
When `--download-media` is enabled:
|
||||||
- Images are saved to `imgs/` next to the markdown file
|
- Images are saved to `imgs/` next to the markdown file
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
import { fetchXArticle } from "./graphql.js";
|
import { fetchXArticle } from "./graphql.js";
|
||||||
import { formatArticleMarkdown } from "./markdown.js";
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
||||||
|
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
|
||||||
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
||||||
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
||||||
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
||||||
@@ -182,36 +183,160 @@ function sanitizeSlug(input: string): string {
|
|||||||
.slice(0, 120);
|
.slice(0, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBackupTimestamp(date: Date = new Date()): string {
|
function extractContentSlug(markdown: string): string {
|
||||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
||||||
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(
|
if (headingMatch?.[1]) {
|
||||||
date.getMinutes()
|
return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase();
|
||||||
)}${pad2(date.getSeconds())}`;
|
}
|
||||||
|
const lines = markdown.split("\n");
|
||||||
|
let inFrontmatter = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line === "---") {
|
||||||
|
inFrontmatter = !inFrontmatter;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFrontmatter) continue;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
return sanitizeSlug(trimmed.slice(0, 60)).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backupDirIfExists(dir: string, log: (message: string) => void): Promise<void> {
|
function resolveSlugAndId(normalizedUrl: string, kind: "tweet" | "article"): { slug: string; idPart: string } {
|
||||||
|
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
||||||
|
const tweetId = kind === "tweet" ? parseTweetId(normalizedUrl) : null;
|
||||||
|
const username = kind === "tweet" ? parseTweetUsername(normalizedUrl) : null;
|
||||||
|
|
||||||
|
const idPart = articleId ?? tweetId ?? String(Date.now());
|
||||||
|
const userSlug = username ? sanitizeSlug(username) : null;
|
||||||
|
const slug = userSlug ?? idPart;
|
||||||
|
return { slug, idPart };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFrontmatterUrls(markdown: string): string[] {
|
||||||
|
const match = markdown.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (!match?.[1]) return [];
|
||||||
|
|
||||||
|
const lines = match[1].split("\n");
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(/^(url|requestedUrl):\s*["']([^"']+)["']\s*$/);
|
||||||
|
if (m?.[2]) {
|
||||||
|
urls.push(m[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frontmatterMatchesTarget(
|
||||||
|
markdown: string,
|
||||||
|
normalizedUrl: string,
|
||||||
|
kind: "tweet" | "article"
|
||||||
|
): boolean {
|
||||||
|
const urls = extractFrontmatterUrls(markdown);
|
||||||
|
if (urls.length === 0) return false;
|
||||||
|
|
||||||
|
const targetId = kind === "article" ? parseArticleId(normalizedUrl) : parseTweetId(normalizedUrl);
|
||||||
|
if (!targetId) return false;
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const candidateId = kind === "article" ? parseArticleId(url) : parseTweetId(url);
|
||||||
|
if (candidateId && candidateId === targetId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMarkdownFiles(dirPath: string): string[] {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(dir)) return;
|
return fs
|
||||||
const stat = fs.statSync(dir);
|
.readdirSync(dirPath)
|
||||||
if (!stat.isDirectory()) return;
|
.filter((name) => name.toLowerCase().endsWith(".md"))
|
||||||
const backup = `${dir}-backup-${formatBackupTimestamp()}`;
|
.map((name) => path.join(dirPath, name))
|
||||||
await rename(dir, backup);
|
.sort();
|
||||||
log(`[x-to-markdown] Existing directory moved to: ${backup}`);
|
} catch {
|
||||||
} catch (error) {
|
return [];
|
||||||
throw new Error(
|
|
||||||
`Failed to backup existing directory (${dir}): ${error instanceof Error ? error.message : String(error ?? "")}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultOutputDir(slug: string): string {
|
function resolveExistingMarkdownPath(
|
||||||
return path.resolve(process.cwd(), "x-to-markdown", slug);
|
normalizedUrl: string,
|
||||||
|
kind: "tweet" | "article",
|
||||||
|
argsOutput: string | null
|
||||||
|
): string | null {
|
||||||
|
const { slug, idPart } = resolveSlugAndId(normalizedUrl, kind);
|
||||||
|
const candidateDirs = new Set<string>();
|
||||||
|
const candidateFiles = new Set<string>();
|
||||||
|
|
||||||
|
if (argsOutput) {
|
||||||
|
const resolved = path.resolve(argsOutput);
|
||||||
|
const looksDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(resolved)) {
|
||||||
|
const stat = fs.statSync(resolved);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
candidateFiles.add(resolved);
|
||||||
|
} else if (stat.isDirectory()) {
|
||||||
|
candidateDirs.add(path.join(resolved, slug, idPart));
|
||||||
|
candidateDirs.add(resolved);
|
||||||
|
}
|
||||||
|
} else if (looksDir) {
|
||||||
|
candidateDirs.add(path.join(resolved, slug, idPart));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidateDirs.add(path.resolve(process.cwd(), "x-to-markdown", slug, idPart));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePath of candidateFiles) {
|
||||||
|
if (!filePath.toLowerCase().endsWith(".md")) continue;
|
||||||
|
try {
|
||||||
|
const markdown = fs.readFileSync(filePath, "utf8");
|
||||||
|
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dirPath of candidateDirs) {
|
||||||
|
if (!fs.existsSync(dirPath)) continue;
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(dirPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!stat.isDirectory()) continue;
|
||||||
|
|
||||||
|
const markdownFiles = listMarkdownFiles(dirPath);
|
||||||
|
for (const filePath of markdownFiles) {
|
||||||
|
try {
|
||||||
|
const markdown = fs.readFileSync(filePath, "utf8");
|
||||||
|
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOutputPath(
|
async function resolveOutputPath(
|
||||||
normalizedUrl: string,
|
normalizedUrl: string,
|
||||||
kind: "tweet" | "article",
|
kind: "tweet" | "article",
|
||||||
argsOutput: string | null,
|
argsOutput: string | null,
|
||||||
|
contentSlug: string,
|
||||||
log: (message: string) => void
|
log: (message: string) => void
|
||||||
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
|
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
|
||||||
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
||||||
@@ -222,15 +347,14 @@ async function resolveOutputPath(
|
|||||||
const idPart = articleId ?? tweetId ?? String(Date.now());
|
const idPart = articleId ?? tweetId ?? String(Date.now());
|
||||||
const slug = userSlug ?? idPart;
|
const slug = userSlug ?? idPart;
|
||||||
|
|
||||||
const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`;
|
const defaultFileName = `${contentSlug}.md`;
|
||||||
|
|
||||||
if (argsOutput) {
|
if (argsOutput) {
|
||||||
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
||||||
const resolved = path.resolve(argsOutput);
|
const resolved = path.resolve(argsOutput);
|
||||||
try {
|
try {
|
||||||
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
|
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
|
||||||
const outputDir = path.join(resolved, slug);
|
const outputDir = path.join(resolved, slug, idPart);
|
||||||
await backupDirIfExists(outputDir, log);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
||||||
}
|
}
|
||||||
@@ -243,8 +367,7 @@ async function resolveOutputPath(
|
|||||||
return { outputDir, markdownPath: resolved, slug };
|
return { outputDir, markdownPath: resolved, slug };
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputDir = resolveDefaultOutputDir(slug);
|
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, idPart);
|
||||||
await backupDirIfExists(outputDir, log);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
||||||
}
|
}
|
||||||
@@ -355,7 +478,8 @@ async function convertArticleToMarkdown(
|
|||||||
|
|
||||||
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
||||||
const article = await fetchXArticle(articleId, cookieMap, false);
|
const article = await fetchXArticle(articleId, cookieMap, false);
|
||||||
const { markdown: body, coverUrl } = formatArticleMarkdown(article);
|
const referencedTweets = await resolveReferencedTweetsFromArticle(article, cookieMap, { log });
|
||||||
|
const { markdown: body, coverUrl } = formatArticleMarkdown(article, { referencedTweets });
|
||||||
|
|
||||||
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
||||||
const meta = formatMetaMarkdown({
|
const meta = formatMetaMarkdown({
|
||||||
@@ -394,13 +518,67 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
||||||
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
|
||||||
|
if (args.downloadMedia) {
|
||||||
|
const existingMarkdownPath = resolveExistingMarkdownPath(normalizedUrl, kind, args.output);
|
||||||
|
if (existingMarkdownPath) {
|
||||||
|
log(`[x-to-markdown] Reusing existing markdown: ${existingMarkdownPath}`);
|
||||||
|
const existingMarkdown = await readFile(existingMarkdownPath, "utf8");
|
||||||
|
const mediaResult = await localizeMarkdownMedia(existingMarkdown, {
|
||||||
|
markdownPath: existingMarkdownPath,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
const didLocalize =
|
||||||
|
mediaResult.downloadedImages > 0 ||
|
||||||
|
mediaResult.downloadedVideos > 0 ||
|
||||||
|
mediaResult.markdown !== existingMarkdown;
|
||||||
|
|
||||||
|
if (didLocalize) {
|
||||||
|
await writeFile(existingMarkdownPath, mediaResult.markdown, "utf8");
|
||||||
|
log(
|
||||||
|
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
|
||||||
|
);
|
||||||
|
log(`[x-to-markdown] Saved: ${existingMarkdownPath}`);
|
||||||
|
|
||||||
|
const { slug } = resolveSlugAndId(normalizedUrl, kind);
|
||||||
|
if (args.json) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
||||||
|
requestedUrl: normalizedUrl,
|
||||||
|
type: kind,
|
||||||
|
slug,
|
||||||
|
outputDir: path.dirname(existingMarkdownPath),
|
||||||
|
markdownPath: existingMarkdownPath,
|
||||||
|
downloadMedia: true,
|
||||||
|
downloadedImages: mediaResult.downloadedImages,
|
||||||
|
downloadedVideos: mediaResult.downloadedVideos,
|
||||||
|
imageDir: mediaResult.imageDir,
|
||||||
|
videoDir: mediaResult.videoDir,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(existingMarkdownPath);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[x-to-markdown] Existing markdown already localized; rebuilding content to refresh placement.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let markdown =
|
let markdown =
|
||||||
kind === "article" && articleId
|
kind === "article" && articleId
|
||||||
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
||||||
: await tweetToMarkdown(normalizedUrl, { log });
|
: await tweetToMarkdown(normalizedUrl, { log });
|
||||||
|
|
||||||
|
const contentSlug = extractContentSlug(markdown);
|
||||||
|
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, contentSlug, log);
|
||||||
|
|
||||||
let mediaResult: LocalizeMarkdownMediaResult | null = null;
|
let mediaResult: LocalizeMarkdownMediaResult | null = null;
|
||||||
|
|
||||||
if (args.downloadMedia) {
|
if (args.downloadMedia) {
|
||||||
|
|||||||
@@ -2,9 +2,22 @@ import type {
|
|||||||
ArticleBlock,
|
ArticleBlock,
|
||||||
ArticleContentState,
|
ArticleContentState,
|
||||||
ArticleEntity,
|
ArticleEntity,
|
||||||
|
ArticleEntityMapEntry,
|
||||||
ArticleMediaInfo,
|
ArticleMediaInfo,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type ReferencedTweetInfo = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
authorName?: string;
|
||||||
|
authorUsername?: string;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormatArticleOptions = {
|
||||||
|
referencedTweets?: Map<string, ReferencedTweetInfo>;
|
||||||
|
};
|
||||||
|
|
||||||
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const candidate = value as ArticleEntity;
|
const candidate = value as ArticleEntity;
|
||||||
@@ -29,6 +42,73 @@ function normalizeCaption(caption?: string): string {
|
|||||||
return trimmed.replace(/\s+/g, " ");
|
return trimmed.replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeTweetText(text?: string): string {
|
||||||
|
const trimmed = text?.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const normalized = trimmed
|
||||||
|
.split(/\r?\n+/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
if (normalized.length <= 280) return normalized;
|
||||||
|
return `${normalized.slice(0, 277)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTweetUrl(tweetId?: string, username?: string): string | null {
|
||||||
|
if (!tweetId) return null;
|
||||||
|
if (username) {
|
||||||
|
return `https://x.com/${username}/status/${tweetId}`;
|
||||||
|
}
|
||||||
|
return `https://x.com/i/web/status/${tweetId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntityLookup = {
|
||||||
|
byIndex: Map<number, ArticleEntityMapEntry>;
|
||||||
|
byLogicalKey: Map<number, ArticleEntityMapEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildEntityLookup(
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined
|
||||||
|
): EntityLookup {
|
||||||
|
const lookup: EntityLookup = {
|
||||||
|
byIndex: new Map<number, ArticleEntityMapEntry>(),
|
||||||
|
byLogicalKey: new Map<number, ArticleEntityMapEntry>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!entityMap) return lookup;
|
||||||
|
|
||||||
|
for (const [idx, entry] of Object.entries(entityMap)) {
|
||||||
|
const idxNum = Number(idx);
|
||||||
|
if (Number.isFinite(idxNum)) {
|
||||||
|
lookup.byIndex.set(idxNum, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logicalKey = parseInt(entry?.key ?? "", 10);
|
||||||
|
if (Number.isFinite(logicalKey) && !lookup.byLogicalKey.has(logicalKey)) {
|
||||||
|
lookup.byLogicalKey.set(logicalKey, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntityEntry(
|
||||||
|
entityKey: number | undefined,
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
lookup: EntityLookup
|
||||||
|
): ArticleEntityMapEntry | undefined {
|
||||||
|
if (entityKey === undefined) return undefined;
|
||||||
|
|
||||||
|
const byLogicalKey = lookup.byLogicalKey.get(entityKey);
|
||||||
|
if (byLogicalKey) return byLogicalKey;
|
||||||
|
|
||||||
|
const byIndex = lookup.byIndex.get(entityKey);
|
||||||
|
if (byIndex) return byIndex;
|
||||||
|
|
||||||
|
if (!entityMap) return undefined;
|
||||||
|
return entityMap[String(entityKey)];
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
||||||
if (!info) return undefined;
|
if (!info) return undefined;
|
||||||
if (info.original_img_url) return info.original_img_url;
|
if (info.original_img_url) return info.original_img_url;
|
||||||
@@ -79,11 +159,12 @@ function collectMediaUrls(
|
|||||||
function resolveEntityMediaLines(
|
function resolveEntityMediaLines(
|
||||||
entityKey: number | undefined,
|
entityKey: number | undefined,
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, string>,
|
||||||
usedUrls: Set<string>
|
usedUrls: Set<string>
|
||||||
): string[] {
|
): string[] {
|
||||||
if (entityKey === undefined || !entityMap) return [];
|
if (entityKey === undefined) return [];
|
||||||
const entry = entityMap[String(entityKey)];
|
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||||
const value = entry?.value;
|
const value = entry?.value;
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
const type = value.type;
|
const type = value.type;
|
||||||
@@ -117,11 +198,142 @@ function resolveEntityMediaLines(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEntityTweetLines(
|
||||||
|
entityKey: number | undefined,
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
|
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||||
|
): string[] {
|
||||||
|
if (entityKey === undefined) return [];
|
||||||
|
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||||
|
const value = entry?.value;
|
||||||
|
if (!value || value.type !== "TWEET") return [];
|
||||||
|
|
||||||
|
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
|
||||||
|
if (!tweetId) return [];
|
||||||
|
|
||||||
|
const referenced = referencedTweets?.get(tweetId);
|
||||||
|
const url =
|
||||||
|
referenced?.url ??
|
||||||
|
buildTweetUrl(tweetId, referenced?.authorUsername) ??
|
||||||
|
`https://x.com/i/web/status/${tweetId}`;
|
||||||
|
|
||||||
|
const authorText =
|
||||||
|
referenced?.authorName && referenced?.authorUsername
|
||||||
|
? `${referenced.authorName} (@${referenced.authorUsername})`
|
||||||
|
: referenced?.authorUsername
|
||||||
|
? `@${referenced.authorUsername}`
|
||||||
|
: referenced?.authorName;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`> 引用推文${authorText ? `:${authorText}` : ""}`);
|
||||||
|
|
||||||
|
const summary = summarizeTweetText(referenced?.text);
|
||||||
|
if (summary) {
|
||||||
|
lines.push(`> ${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`> ${url}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMediaLinkMap(
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined
|
||||||
|
): Map<number, string> {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
if (!entityMap) return map;
|
||||||
|
|
||||||
|
const mediaEntries: { idx: number; key: number }[] = [];
|
||||||
|
const linkEntries: { key: number; url: string }[] = [];
|
||||||
|
|
||||||
|
for (const [idx, entry] of Object.entries(entityMap)) {
|
||||||
|
const value = entry?.value;
|
||||||
|
if (!value) continue;
|
||||||
|
const key = parseInt(entry?.key ?? "", 10);
|
||||||
|
if (isNaN(key)) continue;
|
||||||
|
|
||||||
|
if (value.type === "MEDIA" || value.type === "IMAGE") {
|
||||||
|
mediaEntries.push({ idx: Number(idx), key });
|
||||||
|
} else if (value.type === "LINK" && typeof value.data?.url === "string") {
|
||||||
|
linkEntries.push({ key, url: value.data.url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaEntries.length === 0 || linkEntries.length === 0) return map;
|
||||||
|
|
||||||
|
mediaEntries.sort((a, b) => a.key - b.key);
|
||||||
|
linkEntries.sort((a, b) => a.key - b.key);
|
||||||
|
|
||||||
|
const pool = [...linkEntries];
|
||||||
|
for (const media of mediaEntries) {
|
||||||
|
if (pool.length === 0) break;
|
||||||
|
let linkIdx = pool.findIndex((l) => l.key > media.key);
|
||||||
|
if (linkIdx === -1) linkIdx = 0;
|
||||||
|
const link = pool.splice(linkIdx, 1)[0]!;
|
||||||
|
map.set(media.idx, link.url);
|
||||||
|
map.set(media.key, link.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineLinks(
|
||||||
|
text: string,
|
||||||
|
entityRanges: Array<{ key?: number; offset?: number; length?: number }>,
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
|
mediaLinkMap: Map<number, string>
|
||||||
|
): string {
|
||||||
|
if (!entityMap || entityRanges.length === 0) return text;
|
||||||
|
|
||||||
|
const valid = entityRanges.filter(
|
||||||
|
(r) =>
|
||||||
|
typeof r.key === "number" &&
|
||||||
|
typeof r.offset === "number" &&
|
||||||
|
typeof r.length === "number" &&
|
||||||
|
r.length > 0
|
||||||
|
);
|
||||||
|
if (valid.length === 0) return text;
|
||||||
|
|
||||||
|
const sorted = [...valid].sort((a, b) => (b.offset ?? 0) - (a.offset ?? 0));
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
for (const range of sorted) {
|
||||||
|
const offset = range.offset!;
|
||||||
|
const length = range.length!;
|
||||||
|
const key = range.key!;
|
||||||
|
|
||||||
|
const entry = resolveEntityEntry(key, entityMap, entityLookup);
|
||||||
|
const value = entry?.value;
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
let url: string | undefined;
|
||||||
|
if (value.type === "LINK" && typeof value.data?.url === "string") {
|
||||||
|
url = value.data.url;
|
||||||
|
} else if (value.type === "MEDIA" || value.type === "IMAGE") {
|
||||||
|
url = mediaLinkMap.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) continue;
|
||||||
|
|
||||||
|
const linkText = result.slice(offset, offset + length);
|
||||||
|
result =
|
||||||
|
result.slice(0, offset) +
|
||||||
|
`[${linkText}](${url})` +
|
||||||
|
result.slice(offset + length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function renderContentBlocks(
|
function renderContentBlocks(
|
||||||
blocks: ArticleBlock[],
|
blocks: ArticleBlock[],
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, string>,
|
||||||
usedUrls: Set<string>
|
usedUrls: Set<string>,
|
||||||
|
mediaLinkMap: Map<number, string>,
|
||||||
|
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||||
): string[] {
|
): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
|
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
|
||||||
@@ -150,14 +362,55 @@ function renderContentBlocks(
|
|||||||
const mediaLines: string[] = [];
|
const mediaLines: string[] = [];
|
||||||
for (const range of ranges) {
|
for (const range of ranges) {
|
||||||
if (typeof range?.key !== "number") continue;
|
if (typeof range?.key !== "number") continue;
|
||||||
mediaLines.push(...resolveEntityMediaLines(range.key, entityMap, mediaById, usedUrls));
|
mediaLines.push(
|
||||||
|
...resolveEntityMediaLines(range.key, entityMap, entityLookup, mediaById, usedUrls)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return mediaLines;
|
return mediaLines;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const collectTweetLines = (block: ArticleBlock): string[] => {
|
||||||
|
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||||
|
const tweetLines: string[] = [];
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (typeof range?.key !== "number") continue;
|
||||||
|
tweetLines.push(
|
||||||
|
...resolveEntityTweetLines(range.key, entityMap, entityLookup, referencedTweets)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tweetLines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectLinkLines = (block: ArticleBlock): string[] => {
|
||||||
|
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||||
|
const linkLines: string[] = [];
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (typeof range?.key !== "number") continue;
|
||||||
|
const entry = resolveEntityEntry(range.key, entityMap, entityLookup);
|
||||||
|
const value = entry?.value;
|
||||||
|
if (value?.type !== "LINK") continue;
|
||||||
|
const url = typeof value.data?.url === "string" ? value.data.url : "";
|
||||||
|
if (url) {
|
||||||
|
linkLines.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(linkLines)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushTrailingMedia = (mediaLines: string[]) => {
|
||||||
|
if (mediaLines.length > 0) {
|
||||||
|
pushBlock(mediaLines, "media");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const type = typeof block?.type === "string" ? block.type : "unstyled";
|
const type = typeof block?.type === "string" ? block.type : "unstyled";
|
||||||
const text = typeof block?.text === "string" ? block.text : "";
|
const rawText = typeof block?.text === "string" ? block.text : "";
|
||||||
|
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||||
|
const text =
|
||||||
|
type !== "atomic" && type !== "code-block"
|
||||||
|
? renderInlineLinks(rawText, ranges, entityMap, entityLookup, mediaLinkMap)
|
||||||
|
: rawText;
|
||||||
|
|
||||||
if (type === "code-block") {
|
if (type === "code-block") {
|
||||||
if (!inCodeBlock) {
|
if (!inCodeBlock) {
|
||||||
@@ -182,10 +435,22 @@ function renderContentBlocks(
|
|||||||
}
|
}
|
||||||
listKind = null;
|
listKind = null;
|
||||||
orderedIndex = 0;
|
orderedIndex = 0;
|
||||||
|
|
||||||
|
const tweetLines = collectTweetLines(block);
|
||||||
|
if (tweetLines.length > 0) {
|
||||||
|
pushBlock(tweetLines, "quote");
|
||||||
|
}
|
||||||
|
|
||||||
const mediaLines = collectMediaLines(block);
|
const mediaLines = collectMediaLines(block);
|
||||||
if (mediaLines.length > 0) {
|
if (mediaLines.length > 0) {
|
||||||
pushBlock(mediaLines, "media");
|
pushBlock(mediaLines, "media");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkLines = collectLinkLines(block);
|
||||||
|
if (linkLines.length > 0) {
|
||||||
|
pushBlock(linkLines, "text");
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +464,7 @@ function renderContentBlocks(
|
|||||||
listKind = "unordered";
|
listKind = "unordered";
|
||||||
orderedIndex = 0;
|
orderedIndex = 0;
|
||||||
pushBlock([`- ${text}`], "list");
|
pushBlock([`- ${text}`], "list");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +475,7 @@ function renderContentBlocks(
|
|||||||
listKind = "ordered";
|
listKind = "ordered";
|
||||||
orderedIndex += 1;
|
orderedIndex += 1;
|
||||||
pushBlock([`${orderedIndex}. ${text}`], "list");
|
pushBlock([`${orderedIndex}. ${text}`], "list");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,36 +485,43 @@ function renderContentBlocks(
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "header-one":
|
case "header-one":
|
||||||
pushBlock([`# ${text}`], "heading");
|
pushBlock([`# ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-two":
|
case "header-two":
|
||||||
pushBlock([`## ${text}`], "heading");
|
pushBlock([`## ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-three":
|
case "header-three":
|
||||||
pushBlock([`### ${text}`], "heading");
|
pushBlock([`### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-four":
|
case "header-four":
|
||||||
pushBlock([`#### ${text}`], "heading");
|
pushBlock([`#### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-five":
|
case "header-five":
|
||||||
pushBlock([`##### ${text}`], "heading");
|
pushBlock([`##### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-six":
|
case "header-six":
|
||||||
pushBlock([`###### ${text}`], "heading");
|
pushBlock([`###### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "blockquote": {
|
case "blockquote": {
|
||||||
const quoteLines = text.length > 0 ? text.split("\n") : [""];
|
const quoteLines = text.length > 0 ? text.split("\n") : [""];
|
||||||
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
|
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
if (/^XIMGPH_\d+$/.test(text.trim())) {
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
|
break;
|
||||||
|
}
|
||||||
pushBlock([text], "text");
|
pushBlock([text], "text");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trailingMediaLines = collectMediaLines(block);
|
|
||||||
if (trailingMediaLines.length > 0) {
|
|
||||||
pushBlock(trailingMediaLines, "media");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
@@ -262,7 +536,28 @@ export type FormatArticleResult = {
|
|||||||
coverUrl: string | null;
|
coverUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatArticleMarkdown(article: unknown): FormatArticleResult {
|
export function extractReferencedTweetIds(article: unknown): string[] {
|
||||||
|
const candidate = coerceArticleEntity(article);
|
||||||
|
const entityMap = candidate?.content_state?.entityMap;
|
||||||
|
if (!entityMap) return [];
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const entry of Object.values(entityMap)) {
|
||||||
|
const value = entry?.value;
|
||||||
|
if (value?.type !== "TWEET") continue;
|
||||||
|
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
|
||||||
|
if (!tweetId || seen.has(tweetId)) continue;
|
||||||
|
seen.add(tweetId);
|
||||||
|
ids.push(tweetId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatArticleMarkdown(
|
||||||
|
article: unknown,
|
||||||
|
options: FormatArticleOptions = {}
|
||||||
|
): FormatArticleResult {
|
||||||
const candidate = coerceArticleEntity(article);
|
const candidate = coerceArticleEntity(article);
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
|
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
|
||||||
@@ -283,8 +578,18 @@ export function formatArticleMarkdown(article: unknown): FormatArticleResult {
|
|||||||
|
|
||||||
const blocks = candidate.content_state?.blocks;
|
const blocks = candidate.content_state?.blocks;
|
||||||
const entityMap = candidate.content_state?.entityMap;
|
const entityMap = candidate.content_state?.entityMap;
|
||||||
|
const entityLookup = buildEntityLookup(entityMap);
|
||||||
if (Array.isArray(blocks) && blocks.length > 0) {
|
if (Array.isArray(blocks) && blocks.length > 0) {
|
||||||
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls);
|
const mediaLinkMap = buildMediaLinkMap(entityMap);
|
||||||
|
const rendered = renderContentBlocks(
|
||||||
|
blocks,
|
||||||
|
entityMap,
|
||||||
|
entityLookup,
|
||||||
|
mediaById,
|
||||||
|
usedUrls,
|
||||||
|
mediaLinkMap,
|
||||||
|
options.referencedTweets
|
||||||
|
);
|
||||||
if (rendered.length > 0) {
|
if (rendered.length > 0) {
|
||||||
if (lines.length > 0) lines.push("");
|
if (lines.length > 0) lines.push("");
|
||||||
lines.push(...rendered);
|
lines.push(...rendered);
|
||||||
|
|||||||
@@ -187,11 +187,35 @@ function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extens
|
|||||||
|
|
||||||
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
|
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
|
||||||
|
|
||||||
|
function toHighResUrl(rawUrl: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
if (parsed.hostname !== "pbs.twimg.com") return rawUrl;
|
||||||
|
const ext = path.posix.extname(parsed.pathname).replace(/^\./, "").toLowerCase();
|
||||||
|
if (!ext || !IMAGE_EXTENSIONS.has(ext)) return rawUrl;
|
||||||
|
parsed.pathname = parsed.pathname.replace(new RegExp(`\\.${ext}$`), "");
|
||||||
|
parsed.searchParams.set("format", ext === "jpeg" ? "jpg" : ext);
|
||||||
|
parsed.searchParams.set("name", "4096x4096");
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||||
MARKDOWN_LINK_RE.lastIndex = 0;
|
|
||||||
const candidates: MarkdownLinkCandidate[] = [];
|
const candidates: MarkdownLinkCandidate[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (fmMatch) {
|
||||||
|
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
|
||||||
|
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
|
||||||
|
seen.add(coverMatch[2]);
|
||||||
|
candidates.push({ url: coverMatch[2], hint: "image" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MARKDOWN_LINK_RE.lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
|
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
|
||||||
const label = match[1] ?? "";
|
const label = match[1] ?? "";
|
||||||
@@ -204,15 +228,6 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
|
|
||||||
if (fmMatch) {
|
|
||||||
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
|
|
||||||
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
|
|
||||||
seen.add(coverMatch[2]);
|
|
||||||
candidates.push({ url: coverMatch[2], hint: "image" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +274,8 @@ export async function localizeMarkdownMedia(
|
|||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(candidate.url, {
|
const downloadUrl = toHighResUrl(candidate.url);
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { fetchXTweet } from "./graphql.js";
|
||||||
|
import {
|
||||||
|
extractReferencedTweetIds,
|
||||||
|
type ReferencedTweetInfo,
|
||||||
|
} from "./markdown.js";
|
||||||
|
|
||||||
|
type ResolveReferencedTweetsOptions = {
|
||||||
|
log?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractReferencedTweetInfo(tweet: any, fallbackTweetId: string): ReferencedTweetInfo {
|
||||||
|
const userCore = tweet?.core?.user_results?.result?.core;
|
||||||
|
const userLegacy = tweet?.core?.user_results?.result?.legacy;
|
||||||
|
|
||||||
|
const authorName =
|
||||||
|
typeof userCore?.name === "string"
|
||||||
|
? userCore.name
|
||||||
|
: typeof userLegacy?.name === "string"
|
||||||
|
? userLegacy.name
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const authorUsername =
|
||||||
|
typeof userCore?.screen_name === "string"
|
||||||
|
? userCore.screen_name
|
||||||
|
: typeof userLegacy?.screen_name === "string"
|
||||||
|
? userLegacy.screen_name
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const text =
|
||||||
|
tweet?.note_tweet?.note_tweet_results?.result?.text ??
|
||||||
|
tweet?.legacy?.full_text ??
|
||||||
|
tweet?.legacy?.text ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
const tweetId =
|
||||||
|
typeof tweet?.rest_id === "string" && tweet.rest_id.length > 0
|
||||||
|
? tweet.rest_id
|
||||||
|
: fallbackTweetId;
|
||||||
|
|
||||||
|
const url = authorUsername
|
||||||
|
? `https://x.com/${authorUsername}/status/${tweetId}`
|
||||||
|
: `https://x.com/i/web/status/${tweetId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tweetId,
|
||||||
|
url,
|
||||||
|
authorName,
|
||||||
|
authorUsername,
|
||||||
|
text: typeof text === "string" ? text : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveReferencedTweetsFromArticle(
|
||||||
|
article: unknown,
|
||||||
|
cookieMap: Record<string, string>,
|
||||||
|
options: ResolveReferencedTweetsOptions = {}
|
||||||
|
): Promise<Map<string, ReferencedTweetInfo>> {
|
||||||
|
const log = options.log ?? (() => {});
|
||||||
|
const ids = extractReferencedTweetIds(article);
|
||||||
|
const referencedTweets = new Map<string, ReferencedTweetInfo>();
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const tweet = await fetchXTweet(id, cookieMap, false);
|
||||||
|
const info = extractReferencedTweetInfo(tweet, id);
|
||||||
|
referencedTweets.set(id, info);
|
||||||
|
} catch (error) {
|
||||||
|
log(
|
||||||
|
`[x-to-markdown] Failed to fetch referenced tweet ${id}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
referencedTweets.set(id, {
|
||||||
|
id,
|
||||||
|
url: `https://x.com/i/web/status/${id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return referencedTweets;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { hasRequiredXCookies, loadXCookies } from "./cookies.js";
|
import { hasRequiredXCookies, loadXCookies } from "./cookies.js";
|
||||||
import { fetchTweetThread } from "./thread.js";
|
import { fetchTweetThread } from "./thread.js";
|
||||||
import { formatArticleMarkdown } from "./markdown.js";
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
|
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
|
||||||
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
|
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
|
||||||
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
|
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
|
||||||
|
|
||||||
@@ -129,7 +130,8 @@ export async function tweetToMarkdown(
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (articleEntity) {
|
if (articleEntity) {
|
||||||
const articleResult = formatArticleMarkdown(articleEntity);
|
const referencedTweets = await resolveReferencedTweetsFromArticle(articleEntity, cookieMap, { log });
|
||||||
|
const articleResult = formatArticleMarkdown(articleEntity, { referencedTweets });
|
||||||
coverImage = articleResult.coverUrl;
|
coverImage = articleResult.coverUrl;
|
||||||
const articleMarkdown = articleResult.markdown.trimEnd();
|
const articleMarkdown = articleResult.markdown.trimEnd();
|
||||||
if (articleMarkdown) {
|
if (articleMarkdown) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type ArticleEntityMapEntry = {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
mediaItems?: ArticleEntityMapMediaItem[];
|
mediaItems?: ArticleEntityMapMediaItem[];
|
||||||
url?: string;
|
url?: string;
|
||||||
|
tweetId?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,33 +13,28 @@ Official API-based image generation. Supports OpenAI, Google, DashScope (阿里
|
|||||||
1. `SKILL_DIR` = this SKILL.md file's directory
|
1. `SKILL_DIR` = this SKILL.md file's directory
|
||||||
2. Script path = `${SKILL_DIR}/scripts/main.ts`
|
2. Script path = `${SKILL_DIR}/scripts/main.ts`
|
||||||
|
|
||||||
## Preferences (EXTEND.md)
|
## Step 0: Load Preferences ⛔ BLOCKING
|
||||||
|
|
||||||
Use Bash to check EXTEND.md existence (priority order):
|
**CRITICAL**: This step MUST complete BEFORE any image generation. Do NOT skip or defer.
|
||||||
|
|
||||||
|
Check EXTEND.md existence (priority: project → user):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check project-level first
|
|
||||||
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
||||||
|
|
||||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
┌──────────────────────────────────────────────────┬───────────────────┐
|
| Result | Action |
|
||||||
│ Path │ Location │
|
|--------|--------|
|
||||||
├──────────────────────────────────────────────────┼───────────────────┤
|
| Found | Load, parse, apply settings. If `default_model.[provider]` is null → ask model only (Flow 2) |
|
||||||
│ .baoyu-skills/baoyu-image-gen/EXTEND.md │ Project directory │
|
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save EXTEND.md → Then continue |
|
||||||
├──────────────────────────────────────────────────┼───────────────────┤
|
|
||||||
│ $HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md │ User home │
|
|
||||||
└──────────────────────────────────────────────────┴───────────────────┘
|
|
||||||
|
|
||||||
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
**CRITICAL**: If not found, complete the full setup (provider + model + quality + save location) using AskUserQuestion BEFORE generating any images. Generation is BLOCKED until EXTEND.md is created.
|
||||||
│ Result │ Action │
|
|
||||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
| Path | Location |
|
||||||
│ Found │ Read, parse, apply settings │
|
|------|----------|
|
||||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
||||||
│ Not found │ Use defaults │
|
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
||||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
|
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
|
||||||
|
|
||||||
@@ -87,12 +82,12 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
|
|||||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||||
| `--image <path>` | Output image path (required) |
|
| `--image <path>` | Output image path (required) |
|
||||||
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
|
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
|
||||||
| `--model <id>`, `-m` | Model ID (`--ref` with OpenAI requires GPT Image model, e.g. `gpt-image-1.5`) |
|
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`) |
|
||||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||||
| `--quality normal\|2k` | Quality preset (default: 2k) |
|
| `--quality normal\|2k` | Quality preset (default: 2k) |
|
||||||
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
||||||
| `--ref <files...>` | Reference images. Supported by Google multimodal and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
|
| `--ref <files...>` | Reference images. Supported by Google multimodal (`gemini-3-pro-image-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-image-preview`) and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
|
||||||
| `--n <count>` | Number of images |
|
| `--n <count>` | Number of images |
|
||||||
| `--json` | JSON output |
|
| `--json` | JSON output |
|
||||||
|
|
||||||
@@ -194,7 +189,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
|||||||
- Missing API key → error with setup instructions
|
- Missing API key → error with setup instructions
|
||||||
- Generation failure → auto-retry once
|
- Generation failure → auto-retry once
|
||||||
- Invalid aspect ratio → warning, proceed with default
|
- Invalid aspect ratio → warning, proceed with default
|
||||||
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal or OpenAI GPT Image edits)
|
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; or OpenAI GPT Image edits)
|
||||||
|
|
||||||
## Extension Support
|
## Extension Support
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
---
|
||||||
|
name: first-time-setup
|
||||||
|
description: First-time setup and default model selection flow for baoyu-image-gen
|
||||||
|
---
|
||||||
|
|
||||||
|
# First-Time Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Triggered when:
|
||||||
|
1. No EXTEND.md found → full setup (provider + model + preferences)
|
||||||
|
2. EXTEND.md found but `default_model.[provider]` is null → model selection only
|
||||||
|
|
||||||
|
## Setup Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
No EXTEND.md found EXTEND.md found, model null
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ AskUserQuestion │ │ AskUserQuestion │
|
||||||
|
│ (full setup) │ │ (model only) │
|
||||||
|
└─────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ Create EXTEND.md │ │ Update EXTEND.md │
|
||||||
|
└─────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Continue Continue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flow 1: No EXTEND.md (Full Setup)
|
||||||
|
|
||||||
|
**Language**: Use user's input language or saved language preference.
|
||||||
|
|
||||||
|
Use AskUserQuestion with ALL questions in ONE call:
|
||||||
|
|
||||||
|
### Question 1: Default Provider
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Provider"
|
||||||
|
question: "Default image generation provider?"
|
||||||
|
options:
|
||||||
|
- label: "Google (Recommended)"
|
||||||
|
description: "Gemini multimodal - high quality, reference images, flexible sizes"
|
||||||
|
- label: "OpenAI"
|
||||||
|
description: "GPT Image - consistent quality, reliable output"
|
||||||
|
- label: "DashScope"
|
||||||
|
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
|
||||||
|
- label: "Replicate"
|
||||||
|
description: "Community models - nano-banana-pro, flexible model selection"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 2: Default Google Model
|
||||||
|
|
||||||
|
Only show if user selected Google or auto-detect (no explicit provider).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Google Model"
|
||||||
|
question: "Default Google image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "gemini-3-pro-image-preview (Recommended)"
|
||||||
|
description: "Highest quality, best for production use"
|
||||||
|
- label: "gemini-3.1-flash-image-preview"
|
||||||
|
description: "Fast generation, good quality, lower cost"
|
||||||
|
- label: "gemini-3-flash-preview"
|
||||||
|
description: "Fast generation, balanced quality and speed"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 3: Default Quality
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Quality"
|
||||||
|
question: "Default image quality?"
|
||||||
|
options:
|
||||||
|
- label: "2k (Recommended)"
|
||||||
|
description: "2048px - covers, illustrations, infographics"
|
||||||
|
- label: "normal"
|
||||||
|
description: "1024px - quick previews, drafts"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 4: Save Location
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Save"
|
||||||
|
question: "Where to save preferences?"
|
||||||
|
options:
|
||||||
|
- label: "Project (Recommended)"
|
||||||
|
description: ".baoyu-skills/ (this project only)"
|
||||||
|
- label: "User"
|
||||||
|
description: "~/.baoyu-skills/ (all projects)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Locations
|
||||||
|
|
||||||
|
| Choice | Path | Scope |
|
||||||
|
|--------|------|-------|
|
||||||
|
| Project | `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Current project |
|
||||||
|
| User | `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | All projects |
|
||||||
|
|
||||||
|
### EXTEND.md Template
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
version: 1
|
||||||
|
default_provider: [selected provider or null]
|
||||||
|
default_quality: [selected quality]
|
||||||
|
default_aspect_ratio: null
|
||||||
|
default_image_size: null
|
||||||
|
default_model:
|
||||||
|
google: [selected google model or null]
|
||||||
|
openai: null
|
||||||
|
dashscope: null
|
||||||
|
replicate: null
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Google Model Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Google Model"
|
||||||
|
question: "Choose a default Google image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "gemini-3-pro-image-preview (Recommended)"
|
||||||
|
description: "Highest quality, best for production use"
|
||||||
|
- label: "gemini-3.1-flash-image-preview"
|
||||||
|
description: "Fast generation, good quality, lower cost"
|
||||||
|
- label: "gemini-3-flash-preview"
|
||||||
|
description: "Fast generation, balanced quality and speed"
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAI Model Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "OpenAI Model"
|
||||||
|
question: "Choose a default OpenAI image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "gpt-image-1.5 (Recommended)"
|
||||||
|
description: "Latest GPT Image model, high quality"
|
||||||
|
- label: "gpt-image-1"
|
||||||
|
description: "Previous generation GPT Image model"
|
||||||
|
```
|
||||||
|
|
||||||
|
### DashScope Model Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "DashScope Model"
|
||||||
|
question: "Choose a default DashScope image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "z-image-turbo (Recommended)"
|
||||||
|
description: "Fast generation, good quality"
|
||||||
|
- label: "z-image-ultra"
|
||||||
|
description: "Higher quality, slower generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update EXTEND.md
|
||||||
|
|
||||||
|
After user selects a model:
|
||||||
|
|
||||||
|
1. Read existing EXTEND.md
|
||||||
|
2. If `default_model:` section exists → update the provider-specific key
|
||||||
|
3. If `default_model:` section missing → add the full section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
default_model:
|
||||||
|
google: [value or null]
|
||||||
|
openai: [value or null]
|
||||||
|
dashscope: [value or null]
|
||||||
|
replicate: [value or null]
|
||||||
|
```
|
||||||
|
|
||||||
|
Only set the selected provider's model; leave others as their current value or null.
|
||||||
|
|
||||||
|
## After Setup
|
||||||
|
|
||||||
|
1. Create directory if needed
|
||||||
|
2. Write/update EXTEND.md with frontmatter
|
||||||
|
3. Confirm: "Preferences saved to [path]"
|
||||||
|
4. Continue with image generation
|
||||||
@@ -20,7 +20,7 @@ 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_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
|
||||||
|
|
||||||
default_model:
|
default_model:
|
||||||
google: null # e.g., "gemini-3-pro-image-preview"
|
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||||
openai: null # e.g., "gpt-image-1.5"
|
openai: null # e.g., "gpt-image-1.5"
|
||||||
dashscope: null # e.g., "z-image-turbo"
|
dashscope: null # e.g., "z-image-turbo"
|
||||||
replicate: null # e.g., "google/nano-banana-pro"
|
replicate: null # e.g., "google/nano-banana-pro"
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import type { CliArgs } from "../types";
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
const GOOGLE_MULTIMODAL_MODELS = ["gemini-3-pro-image-preview", "gemini-3-flash-preview"];
|
const GOOGLE_MULTIMODAL_MODELS = [
|
||||||
const GOOGLE_IMAGEN_MODELS = ["imagen-3.0-generate-002", "imagen-3.0-generate-001"];
|
"gemini-3-pro-image-preview",
|
||||||
|
"gemini-3-flash-preview",
|
||||||
|
"gemini-3.1-flash-image-preview",
|
||||||
|
];
|
||||||
|
const GOOGLE_IMAGEN_MODELS = [
|
||||||
|
"imagen-3.0-generate-002",
|
||||||
|
"imagen-3.0-generate-001",
|
||||||
|
];
|
||||||
|
|
||||||
export function getDefaultModel(): string {
|
export function getDefaultModel(): string {
|
||||||
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
|
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
|
||||||
@@ -33,7 +41,8 @@ function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getGoogleBaseUrl(): string {
|
function getGoogleBaseUrl(): string {
|
||||||
const base = process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
|
const base =
|
||||||
|
process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
|
||||||
return base.replace(/\/+$/g, "");
|
return base.replace(/\/+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,11 +58,46 @@ function toModelPath(model: string): string {
|
|||||||
return `models/${modelId}`;
|
return `models/${modelId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
|
function getHttpProxy(): string | null {
|
||||||
const apiKey = getGoogleApiKey();
|
return (
|
||||||
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
|
process.env.https_proxy ||
|
||||||
|
process.env.HTTPS_PROXY ||
|
||||||
|
process.env.http_proxy ||
|
||||||
|
process.env.HTTP_PROXY ||
|
||||||
|
process.env.ALL_PROXY ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(buildGoogleUrl(pathname), {
|
async function postGoogleJsonViaCurl<T>(
|
||||||
|
url: string,
|
||||||
|
apiKey: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const proxy = getHttpProxy();
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
const proxyArgs = proxy ? `-x "${proxy}"` : "";
|
||||||
|
|
||||||
|
const result = execSync(
|
||||||
|
`curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`,
|
||||||
|
{ input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.toString()) as any;
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(
|
||||||
|
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postGoogleJsonViaFetch<T>(
|
||||||
|
url: string,
|
||||||
|
apiKey: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -70,7 +114,30 @@ async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPromptWithAspect(prompt: string, ar: string | null, quality: CliArgs["quality"]): string {
|
async function postGoogleJson<T>(pathname: string, body: unknown): Promise<T> {
|
||||||
|
const apiKey = getGoogleApiKey();
|
||||||
|
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
|
||||||
|
|
||||||
|
const url = buildGoogleUrl(pathname);
|
||||||
|
const proxy = getHttpProxy();
|
||||||
|
|
||||||
|
// When an HTTP proxy is detected, use curl instead of fetch.
|
||||||
|
// Bun's fetch has a known issue where long-lived connections through
|
||||||
|
// HTTP proxies get their sockets closed unexpectedly, causing image
|
||||||
|
// generation requests to fail with "socket connection was closed
|
||||||
|
// unexpectedly". Using curl as the HTTP client works around this.
|
||||||
|
if (proxy) {
|
||||||
|
return postGoogleJsonViaCurl<T>(url, apiKey, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return postGoogleJsonViaFetch<T>(url, apiKey, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromptWithAspect(
|
||||||
|
prompt: string,
|
||||||
|
ar: string | null,
|
||||||
|
quality: CliArgs["quality"],
|
||||||
|
): string {
|
||||||
let result = prompt;
|
let result = prompt;
|
||||||
if (ar) {
|
if (ar) {
|
||||||
result += ` Aspect ratio: ${ar}.`;
|
result += ` Aspect ratio: ${ar}.`;
|
||||||
@@ -86,7 +153,9 @@ function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
|
|||||||
return `${prompt} Aspect ratio: ${ar}.`;
|
return `${prompt} Aspect ratio: ${ar}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: string }> {
|
async function readImageAsBase64(
|
||||||
|
p: string,
|
||||||
|
): Promise<{ data: string; mimeType: string }> {
|
||||||
const buf = await readFile(p);
|
const buf = await readFile(p);
|
||||||
const ext = path.extname(p).toLowerCase();
|
const ext = path.extname(p).toLowerCase();
|
||||||
let mimeType = "image/png";
|
let mimeType = "image/png";
|
||||||
@@ -97,7 +166,9 @@ async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractInlineImageData(response: {
|
function extractInlineImageData(response: {
|
||||||
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>;
|
candidates?: Array<{
|
||||||
|
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
|
||||||
|
}>;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
for (const candidate of response.candidates || []) {
|
for (const candidate of response.candidates || []) {
|
||||||
for (const part of candidate.content?.parts || []) {
|
for (const part of candidate.content?.parts || []) {
|
||||||
@@ -112,16 +183,21 @@ function extractPredictedImageData(response: {
|
|||||||
predictions?: Array<any>;
|
predictions?: Array<any>;
|
||||||
generatedImages?: Array<any>;
|
generatedImages?: Array<any>;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const candidates = [...(response.predictions || []), ...(response.generatedImages || [])];
|
const candidates = [
|
||||||
|
...(response.predictions || []),
|
||||||
|
...(response.generatedImages || []),
|
||||||
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate || typeof candidate !== "object") continue;
|
if (!candidate || typeof candidate !== "object") continue;
|
||||||
if (typeof candidate.imageBytes === "string") return candidate.imageBytes;
|
if (typeof candidate.imageBytes === "string") return candidate.imageBytes;
|
||||||
if (typeof candidate.bytesBase64Encoded === "string") return candidate.bytesBase64Encoded;
|
if (typeof candidate.bytesBase64Encoded === "string")
|
||||||
|
return candidate.bytesBase64Encoded;
|
||||||
if (typeof candidate.data === "string") return candidate.data;
|
if (typeof candidate.data === "string") return candidate.data;
|
||||||
const image = candidate.image;
|
const image = candidate.image;
|
||||||
if (image && typeof image === "object") {
|
if (image && typeof image === "object") {
|
||||||
if (typeof image.imageBytes === "string") return image.imageBytes;
|
if (typeof image.imageBytes === "string") return image.imageBytes;
|
||||||
if (typeof image.bytesBase64Encoded === "string") return image.bytesBase64Encoded;
|
if (typeof image.bytesBase64Encoded === "string")
|
||||||
|
return image.bytesBase64Encoded;
|
||||||
if (typeof image.data === "string") return image.data;
|
if (typeof image.data === "string") return image.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,10 +207,13 @@ function extractPredictedImageData(response: {
|
|||||||
async function generateWithGemini(
|
async function generateWithGemini(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
model: string,
|
model: string,
|
||||||
args: CliArgs
|
args: CliArgs,
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio);
|
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio);
|
||||||
const parts: Array<{ text?: string; inlineData?: { data: string; mimeType: string } }> = [];
|
const parts: Array<{
|
||||||
|
text?: string;
|
||||||
|
inlineData?: { data: string; mimeType: string };
|
||||||
|
}> = [];
|
||||||
for (const refPath of args.referenceImages) {
|
for (const refPath of args.referenceImages) {
|
||||||
const { data, mimeType } = await readImageAsBase64(refPath);
|
const { data, mimeType } = await readImageAsBase64(refPath);
|
||||||
parts.push({ inlineData: { data, mimeType } });
|
parts.push({ inlineData: { data, mimeType } });
|
||||||
@@ -147,7 +226,9 @@ async function generateWithGemini(
|
|||||||
|
|
||||||
console.log("Generating image with Gemini...", imageConfig);
|
console.log("Generating image with Gemini...", imageConfig);
|
||||||
const response = await postGoogleJson<{
|
const response = await postGoogleJson<{
|
||||||
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>;
|
candidates?: Array<{
|
||||||
|
content?: { parts?: Array<{ inlineData?: { data?: string } }> };
|
||||||
|
}>;
|
||||||
}>(`${toModelPath(model)}:generateContent`, {
|
}>(`${toModelPath(model)}:generateContent`, {
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
@@ -171,12 +252,18 @@ async function generateWithGemini(
|
|||||||
async function generateWithImagen(
|
async function generateWithImagen(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
model: string,
|
model: string,
|
||||||
args: CliArgs
|
args: CliArgs,
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality);
|
const fullPrompt = buildPromptWithAspect(
|
||||||
|
prompt,
|
||||||
|
args.aspectRatio,
|
||||||
|
args.quality,
|
||||||
|
);
|
||||||
const imageSize = getGoogleImageSize(args);
|
const imageSize = getGoogleImageSize(args);
|
||||||
if (imageSize === "4K") {
|
if (imageSize === "4K") {
|
||||||
console.error("Warning: Imagen models do not support 4K imageSize, using 2K instead.");
|
console.error(
|
||||||
|
"Warning: Imagen models do not support 4K imageSize, using 2K instead.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters: Record<string, unknown> = {
|
const parameters: Record<string, unknown> = {
|
||||||
@@ -212,12 +299,12 @@ async function generateWithImagen(
|
|||||||
export async function generateImage(
|
export async function generateImage(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
model: string,
|
model: string,
|
||||||
args: CliArgs
|
args: CliArgs,
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
if (isGoogleImagen(model)) {
|
if (isGoogleImagen(model)) {
|
||||||
if (args.referenceImages.length > 0) {
|
if (args.referenceImages.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
|
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return generateWithImagen(prompt, model, args);
|
return generateWithImagen(prompt, model, args);
|
||||||
@@ -225,7 +312,7 @@ export async function generateImage(
|
|||||||
|
|
||||||
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
|
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview or gemini-3-flash-preview."
|
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user