Compare commits

...

1 Commits

Author SHA1 Message Date
Jim Liu 宝玉 7b0c9ca372 chore: release v1.21.0 2026-01-24 03:33:10 -06:00
5 changed files with 181 additions and 76 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.20.0"
"version": "1.21.0"
},
"plugins": [
{
+6
View File
@@ -2,6 +2,12 @@
English | [中文](./CHANGELOG.zh.md)
## 1.21.0 - 2026-01-24
### Features
- `baoyu-cover-image`: expands aspect ratio options—adds 4:3, 3:2, 3:4 ratios; changes default from 2.35:1 to 16:9 for better versatility. Aspect ratio is now always confirmed unless explicitly specified via `--aspect` flag.
- `baoyu-image-gen`: refactors Google provider to support both Gemini multimodal and Imagen models with unified API. Adds `--imageSize` parameter support (1K/2K/4K) for Gemini models.
## 1.20.0 - 2026-01-24
### Features
+6
View File
@@ -2,6 +2,12 @@
[English](./CHANGELOG.md) | 中文
## 1.21.0 - 2026-01-24
### 新功能
- `baoyu-cover-image`:扩展宽高比选项——新增 4:3、3:2、3:4 比例;默认值从 2.35:1 改为 16:9 以提高通用性。现在除非通过 `--aspect` 标志明确指定,否则始终确认宽高比。
- `baoyu-image-gen`:重构 Google provider 以统一支持 Gemini 多模态和 Imagen 模型。为 Gemini 模型新增 `--imageSize` 参数支持(1K/2K/4K)。
## 1.20.0 - 2026-01-24
### 新功能
+35 -22
View File
@@ -40,7 +40,7 @@ Generate elegant cover images for articles with 4-dimensional customization.
| `--style <name>` | Cover style (see Style Gallery) |
| `--text <level>` | Text density: none, title-only, title-subtitle, text-rich |
| `--mood <level>` | Emotional intensity: subtle, balanced, bold |
| `--aspect <ratio>` | 2.35:1 (default), 16:9, 1:1 |
| `--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` |
| `--quick` | Skip confirmation, use auto-selection for missing dimensions |
@@ -357,14 +357,18 @@ options:
header: "Aspect"
question: "Default aspect ratio for cover images?"
options:
- label: "2.35:1 (Recommended)"
description: "Cinematic widescreen, best for article headers"
- label: "16:9"
description: "Standard widescreen, versatile"
- label: "16:9 (Recommended)"
description: "Standard widescreen - YouTube, presentations, versatile"
- label: "2.35:1"
description: "Cinematic widescreen - article headers, blog posts"
- label: "1:1"
description: "Square, social media friendly"
description: "Square - Instagram, WeChat, social cards"
- label: "3:4"
description: "Portrait - Xiaohongshu, Pinterest, mobile content"
```
Note: More ratios (4:3, 3:2) available during generation. This sets the default recommendation.
**Q5: Quick Mode**
```yaml
header: "Quick"
@@ -415,17 +419,19 @@ Read source content, save it if needed, and perform analysis.
### Step 2: Confirm Options ⚠️
**Purpose**: Validate all 4 dimensions. **Skip if `--quick` flag OR all 4 dimensions specified via CLI.**
**Purpose**: Validate all 4 dimensions + aspect ratio.
**Skip Conditions**:
| Condition | Behavior |
|-----------|----------|
| `--quick` flag | Auto-select missing dimensions, skip to Step 3 |
| All 4 dimensions specified | Use specified values, skip to Step 3 |
| `quick_mode: true` in EXTEND.md | Auto-select missing dimensions, skip to Step 3 |
| Otherwise | Show confirmation (current behavior) |
| Condition | Skipped Questions | Still Asked |
|-----------|-------------------|-------------|
| `--quick` flag | Type, Style, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
| All 4 dimensions + `--aspect` specified | All | None |
| `quick_mode: true` in EXTEND.md | Type, Style, Text, Mood | **Aspect Ratio** (unless `--aspect` specified) |
| Otherwise | None | All 5 questions |
**Quick Mode Output** (when skipping confirmation):
**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 4 dimensions):
```
Quick Mode: Auto-selected dimensions
@@ -433,9 +439,8 @@ Quick Mode: Auto-selected dimensions
• Style: [style] ([reason])
• Text: [text] ([reason])
• Mood: [mood] ([reason])
• Aspect: [aspect]
Generating...
[Then ask Question 5: Aspect Ratio]
```
**Confirmation Flow** (when NOT skipping):
@@ -513,19 +518,27 @@ options:
description: "High contrast, vivid colors, dynamic"
```
**Question 5: Aspect Ratio** (if not specified via `--aspect`)
**Question 5: Aspect Ratio** (ALWAYS ask unless `--aspect` specified via CLI)
Note: Even if user has a preset in EXTEND.md, still ask this question. The preset is shown as the recommended option.
```yaml
header: "Aspect"
question: "Cover aspect ratio?"
multiSelect: false
options:
- label: "2.35:1 (Recommended)"
description: "Cinematic widescreen, best for article headers"
- label: "16:9"
description: "Standard widescreen, versatile"
- label: "[user preset or 16:9] (Recommended)"
description: "[based on preset or default: Standard widescreen, versatile]"
- label: "2.35:1"
description: "Cinematic widescreen - article headers, blog posts"
- label: "4:3"
description: "Traditional screen - PPT slides, classic displays"
- label: "3:2"
description: "Photography ratio - blog articles, Medium posts"
- label: "1:1"
description: "Square, social media friendly"
description: "Square - Instagram, WeChat moments, social cards"
- label: "3:4"
description: "Portrait - Xiaohongshu, Pinterest, mobile-first content"
```
**After response**: Proceed to Step 3 with confirmed dimensions.
@@ -2,19 +2,25 @@ import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
const GOOGLE_MULTIMODAL_MODELS = ["gemini-3-pro-image-preview"];
const GOOGLE_MULTIMODAL_MODELS = ["gemini-3-pro-image-preview", "gemini-3-flash-preview"];
const GOOGLE_IMAGEN_MODELS = ["imagen-3.0-generate-002", "imagen-3.0-generate-001"];
export function getDefaultModel(): string {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
}
function normalizeGoogleModelId(model: string): string {
return model.startsWith("models/") ? model.slice("models/".length) : model;
}
function isGoogleMultimodal(model: string): boolean {
return GOOGLE_MULTIMODAL_MODELS.some((m) => model.includes(m));
const normalized = normalizeGoogleModelId(model);
return GOOGLE_MULTIMODAL_MODELS.some((m) => normalized.includes(m));
}
function isGoogleImagen(model: string): boolean {
return GOOGLE_IMAGEN_MODELS.some((m) => model.includes(m));
const normalized = normalizeGoogleModelId(model);
return GOOGLE_IMAGEN_MODELS.some((m) => normalized.includes(m));
}
function getGoogleApiKey(): string | null {
@@ -26,6 +32,44 @@ function getGoogleImageSize(args: CliArgs): "1K" | "2K" | "4K" {
return args.quality === "2k" ? "2K" : "1K";
}
function getGoogleBaseUrl(): string {
const base = process.env.GOOGLE_BASE_URL || "https://generativelanguage.googleapis.com";
return base.replace(/\/+$/g, "");
}
function buildGoogleUrl(pathname: string): string {
const base = getGoogleBaseUrl();
const cleanedPath = pathname.replace(/^\/+/g, "");
if (base.endsWith("/v1beta")) return `${base}/${cleanedPath}`;
return `${base}/v1beta/${cleanedPath}`;
}
function toModelPath(model: string): string {
const modelId = normalizeGoogleModelId(model);
return `models/${modelId}`;
}
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 res = await fetch(buildGoogleUrl(pathname), {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Google API error (${res.status}): ${err}`);
}
return (await res.json()) as T;
}
function buildPromptWithAspect(prompt: string, ar: string | null, quality: CliArgs["quality"]): string {
let result = prompt;
if (ar) {
@@ -37,6 +81,11 @@ function buildPromptWithAspect(prompt: string, ar: string | null, quality: CliAr
return result;
}
function addAspectRatioToPrompt(prompt: string, ar: string | null): string {
if (!ar) return prompt;
return `${prompt} Aspect ratio: ${ar}.`;
}
async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: string }> {
const buf = await readFile(p);
const ext = path.extname(p).toLowerCase();
@@ -47,53 +96,74 @@ async function readImageAsBase64(p: string): Promise<{ data: string; mimeType: s
return { data: buf.toString("base64"), mimeType };
}
function extractInlineImageData(response: {
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>;
}): string | null {
for (const candidate of response.candidates || []) {
for (const part of candidate.content?.parts || []) {
const data = part.inlineData?.data;
if (typeof data === "string" && data.length > 0) return data;
}
}
return null;
}
function extractPredictedImageData(response: {
predictions?: Array<any>;
generatedImages?: Array<any>;
}): string | null {
const candidates = [...(response.predictions || []), ...(response.generatedImages || [])];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue;
if (typeof candidate.imageBytes === "string") return candidate.imageBytes;
if (typeof candidate.bytesBase64Encoded === "string") return candidate.bytesBase64Encoded;
if (typeof candidate.data === "string") return candidate.data;
const image = candidate.image;
if (image && typeof image === "object") {
if (typeof image.imageBytes === "string") return image.imageBytes;
if (typeof image.bytesBase64Encoded === "string") return image.bytesBase64Encoded;
if (typeof image.data === "string") return image.data;
}
}
return null;
}
async function generateWithGemini(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const { GoogleGenAI } = await import("@google/genai");
const apiKey = getGoogleApiKey();
if (!apiKey) throw new Error("GOOGLE_API_KEY or GEMINI_API_KEY is required");
const ai = new GoogleGenAI({
apiKey,
httpOptions: {
baseUrl: process.env.GOOGLE_BASE_URL || undefined,
},
});
const input: Array<{ type: "text" | "image"; text?: string; data?: string; mime_type?: string }> = [];
const promptWithAspect = addAspectRatioToPrompt(prompt, args.aspectRatio);
const parts: Array<{ text?: string; inlineData?: { data: string; mimeType: string } }> = [];
for (const refPath of args.referenceImages) {
const { data, mimeType } = await readImageAsBase64(refPath);
input.push({ type: "image", data, mime_type: mimeType });
parts.push({ inlineData: { data, mimeType } });
}
input.push({ type: "text", text: prompt });
parts.push({ text: promptWithAspect });
const imageConfig: { image_size: "1K" | "2K" | "4K"; aspect_ratio?: string } = {
image_size: getGoogleImageSize(args),
const imageConfig: { imageSize: "1K" | "2K" | "4K" } = {
imageSize: getGoogleImageSize(args),
};
if (args.aspectRatio) {
imageConfig.aspect_ratio = args.aspectRatio;
}
console.log("Generating image with Gemini...", imageConfig);
const interaction = await ai.interactions.create({
model,
input,
response_modalities: ["image"],
generation_config: {
image_config: imageConfig,
const response = await postGoogleJson<{
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>;
}>(`${toModelPath(model)}:generateContent`, {
contents: [
{
role: "user",
parts,
},
],
generationConfig: {
responseModalities: ["IMAGE"],
imageConfig,
},
});
console.log("Generation completed.");
for (const output of interaction.outputs || []) {
if (output.type === "image" && output.data) {
return Uint8Array.from(Buffer.from(output.data, "base64"));
}
}
const imageData = extractInlineImageData(response);
if (imageData) return Uint8Array.from(Buffer.from(imageData, "base64"));
throw new Error("No image in response");
}
@@ -103,30 +173,40 @@ async function generateWithImagen(
model: string,
args: CliArgs
): Promise<Uint8Array> {
const { experimental_generateImage: generateImage } = await import("ai");
const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
const google = createGoogleGenerativeAI({
apiKey: getGoogleApiKey() || undefined,
baseURL: process.env.GOOGLE_BASE_URL,
});
const fullPrompt = buildPromptWithAspect(prompt, args.aspectRatio, args.quality);
const imageSize = getGoogleImageSize(args);
if (imageSize === "4K") {
console.error("Warning: Imagen models do not support 4K imageSize, using 2K instead.");
}
const result = await generateImage({
model: google.image(model),
prompt: fullPrompt,
n: args.n,
aspectRatio: args.aspectRatio || undefined,
const parameters: Record<string, unknown> = {
sampleCount: args.n,
};
if (args.aspectRatio) {
parameters.aspectRatio = args.aspectRatio;
}
if (imageSize === "1K" || imageSize === "2K") {
parameters.imageSize = imageSize;
} else {
parameters.imageSize = "2K";
}
const response = await postGoogleJson<{
predictions?: Array<any>;
generatedImages?: Array<any>;
}>(`${toModelPath(model)}:predict`, {
instances: [
{
prompt: fullPrompt,
},
],
parameters,
});
const img = result.images[0];
if (!img) throw new Error("No image in response");
const imageData = extractPredictedImageData(response);
if (imageData) return Uint8Array.from(Buffer.from(imageData, "base64"));
if (img.uint8Array) return img.uint8Array;
if (img.base64) return Uint8Array.from(Buffer.from(img.base64, "base64"));
throw new Error("Cannot extract image data");
throw new Error("No image in response");
}
export async function generateImage(