diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 69ffbb3..747b1aa 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Skills shared by Baoyu for improving daily work efficiency", - "version": "1.20.0" + "version": "1.21.0" }, "plugins": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 807b474..2e817b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 635d848..022e5f1 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -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 ### 新功能 diff --git a/skills/baoyu-cover-image/SKILL.md b/skills/baoyu-cover-image/SKILL.md index aa40397..bcbb8d5 100644 --- a/skills/baoyu-cover-image/SKILL.md +++ b/skills/baoyu-cover-image/SKILL.md @@ -40,7 +40,7 @@ Generate elegant cover images for articles with 4-dimensional customization. | `--style ` | Cover style (see Style Gallery) | | `--text ` | Text density: none, title-only, title-subtitle, text-rich | | `--mood ` | Emotional intensity: subtle, balanced, bold | -| `--aspect ` | 2.35:1 (default), 16:9, 1:1 | +| `--aspect ` | 16:9 (default), 2.35:1, 4:3, 3:2, 1:1, 3:4 | | `--lang ` | 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. diff --git a/skills/baoyu-image-gen/scripts/providers/google.ts b/skills/baoyu-image-gen/scripts/providers/google.ts index 4b3ef86..fa17bfc 100644 --- a/skills/baoyu-image-gen/scripts/providers/google.ts +++ b/skills/baoyu-image-gen/scripts/providers/google.ts @@ -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(pathname: string, body: unknown): Promise { + 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; + generatedImages?: Array; +}): 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 { - 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 { - 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 = { + 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; + generatedImages?: Array; + }>(`${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(