diff --git a/RULES.md b/RULES.md index 6673a56..d603160 100644 --- a/RULES.md +++ b/RULES.md @@ -34,6 +34,7 @@ - VPS 数据持久化在 `/opt/ai-toy-patent-workflow/data` - VPS 生产环境变量在 `/opt/ai-toy-patent-workflow/deploy/.env.production`,不入库 - 资源索引:运行 `npm run resources:index` 生成 `data/resource-index.json`、`data/resource-index.md` 和 `data/named/` 人类可读软链接;原始资源文件名不能直接改,避免 session JSON / 图片 URL 断链 +- 风格示意图:运行 `npm run styles:previews -- --force` 用 GPT 图片模型生成 `public/style-previews/*.png`;UI 左侧风格卡片直接引用这些小图 ## 环境变量 - `OPENAI_API_KEY` — GPT API Key;文本/结构化/图片生成统一走 GPT 最高规格配置 diff --git a/package.json b/package.json index 1676dba..1fe65f1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "docker:up": "docker compose up -d --build", "docker:down": "docker compose down", "docker:logs": "docker compose logs -f web", - "resources:index": "node scripts/build-resource-index.mjs data" + "resources:index": "node scripts/build-resource-index.mjs data", + "styles:previews": "node scripts/generate-style-previews.mjs" }, "dependencies": { "next": "^15.5.18", diff --git a/public/style-previews/blueprint.png b/public/style-previews/blueprint.png new file mode 100644 index 0000000..95c8164 Binary files /dev/null and b/public/style-previews/blueprint.png differ diff --git a/public/style-previews/cyber.png b/public/style-previews/cyber.png new file mode 100644 index 0000000..1ec507b Binary files /dev/null and b/public/style-previews/cyber.png differ diff --git a/public/style-previews/kawaii.png b/public/style-previews/kawaii.png new file mode 100644 index 0000000..fd93a3f Binary files /dev/null and b/public/style-previews/kawaii.png differ diff --git a/public/style-previews/mecha.png b/public/style-previews/mecha.png new file mode 100644 index 0000000..fc60a76 Binary files /dev/null and b/public/style-previews/mecha.png differ diff --git a/public/style-previews/minimal.png b/public/style-previews/minimal.png new file mode 100644 index 0000000..b8cb7cd Binary files /dev/null and b/public/style-previews/minimal.png differ diff --git a/public/style-previews/none.png b/public/style-previews/none.png new file mode 100644 index 0000000..992f1bd Binary files /dev/null and b/public/style-previews/none.png differ diff --git a/public/style-previews/plush.png b/public/style-previews/plush.png new file mode 100644 index 0000000..52adc24 Binary files /dev/null and b/public/style-previews/plush.png differ diff --git a/scripts/generate-style-previews.mjs b/scripts/generate-style-previews.mjs new file mode 100644 index 0000000..f53c663 --- /dev/null +++ b/scripts/generate-style-previews.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const envPath = path.join(root, '.env.local'); +const outputDir = path.join(root, 'public', 'style-previews'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)\s*$/); + if (!match) continue; + const [, key, rawValue] = match; + if (process.env[key]) continue; + process.env[key] = rawValue.replace(/^['"]|['"]$/g, ''); + } +} + +const styles = [ + { + id: 'none', + label: 'neutral toy concept', + prompt: 'neutral original toy mascot concept preview, small rounded collectible character, clean studio light, no text, no logo, no watermark, square UI thumbnail', + }, + { + id: 'plush', + label: 'plush toy', + prompt: 'plush toy style preview, soft fuzzy fabric original rounded mascot, stitched details, warm studio light, no text, no logo, no watermark, square UI thumbnail', + }, + { + id: 'mecha', + label: 'mecha toy', + prompt: 'mecha toy style preview, original rounded robot collectible, polished armor panels, tiny mechanical joints, clean dramatic light, no text, no logo, no watermark, square UI thumbnail', + }, + { + id: 'kawaii', + label: 'kawaii toy', + prompt: 'kawaii toy style preview, original cute rounded mascot, soft pastel colors, friendly expression, clean studio background, no text, no logo, no watermark, square UI thumbnail', + }, + { + id: 'blueprint', + label: 'patent blueprint', + prompt: 'patent blueprint style preview, original toy mascot shown as clean white technical line art on deep blue blueprint background, no readable text, no logo, no watermark, square UI thumbnail', + }, + { + id: 'cyber', + label: 'cyberpunk toy', + prompt: 'cyberpunk toy style preview, original rounded collectible mascot, neon rim light, glossy dark materials, futuristic display glow, no text, no logo, no watermark, square UI thumbnail', + }, + { + id: 'minimal', + label: 'minimal toy', + prompt: 'minimal toy style preview, original rounded mascot, simple geometric silhouette, restrained colors, premium clean product render, no text, no logo, no watermark, square UI thumbnail', + }, +]; + +async function imageToBuffer(payload, apiKey) { + const first = payload?.data?.[0]; + if (!first) throw new Error('missing image data'); + if (first.b64_json) return Buffer.from(first.b64_json, 'base64'); + if (first.url) { + const res = await fetch(first.url, { headers: { Authorization: `Bearer ${apiKey}` } }); + if (!res.ok) throw new Error(`image url fetch ${res.status}: ${await res.text()}`); + return Buffer.from(await res.arrayBuffer()); + } + throw new Error('image payload has no b64_json or url'); +} + +async function main() { + loadEnvFile(envPath); + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error('OPENAI_API_KEY missing'); + + const apiBase = process.env.GPT_API_BASE || 'https://api.openai.com/v1'; + const model = process.env.GPT_IMAGE_MODEL || 'gpt-image-2'; + fs.mkdirSync(outputDir, { recursive: true }); + + for (const style of styles) { + const out = path.join(outputDir, `${style.id}.png`); + if (fs.existsSync(out) && !process.argv.includes('--force')) { + console.log(`skip ${style.id}`); + continue; + } + console.log(`generate ${style.id}`); + const res = await fetch(`${apiBase}/images/generations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + prompt: style.prompt, + n: 1, + size: '1024x1024', + }), + }); + if (!res.ok) throw new Error(`GPT image ${style.id} ${res.status}: ${await res.text()}`); + fs.writeFileSync(out, await imageToBuffer(await res.json(), apiKey)); + } +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/src/app/api/gallery/[sessionId]/route.ts b/src/app/api/gallery/[sessionId]/route.ts index 8b2c101..ba0efab 100644 --- a/src/app/api/gallery/[sessionId]/route.ts +++ b/src/app/api/gallery/[sessionId]/route.ts @@ -171,13 +171,35 @@ function renderPage(opts: { .card { position: relative; background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: visible; } .card a { position: relative; display: block; border-radius: 12px 12px 0 0; background: #fff; overflow: hidden; } .thumb { width: 100%; aspect-ratio: var(--ratio, 1 / 1); object-fit: contain; display: block; background: #fff; } - .zoom { display: none; position: absolute; left: 50%; top: 10px; width: min(620px, 82vw); max-height: 82vh; object-fit: contain; transform: translate(-50%, -6%); z-index: 100; background: #fff; border: 1px solid #3b3e47; border-radius: 14px; box-shadow: 0 24px 90px rgba(0,0,0,.72); pointer-events: none; } + .zoom { display: none; position: fixed; left: var(--zoom-left, 20px); top: var(--zoom-top, 20px); width: min(620px, 82vw); max-height: 82vh; object-fit: contain; z-index: 100; background: #fff; border: 1px solid #3b3e47; border-radius: 14px; box-shadow: 0 24px 90px rgba(0,0,0,.72); pointer-events: none; } .card:hover { z-index: 20; border-color: #565b68; } .card:hover .zoom { display: block; } .meta { padding: 10px; display: grid; gap: 5px; font-size: 11px; color: #9ca3af; } .meta strong { color: #f5f5f5; font-size: 12px; line-height: 1.25; } .empty { color: #9ca3af; } +
diff --git a/src/components/HoverImagePreview.tsx b/src/components/HoverImagePreview.tsx new file mode 100644 index 0000000..f2a2d43 --- /dev/null +++ b/src/components/HoverImagePreview.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState } from 'react'; +import type { PointerEvent } from 'react'; + +type PreviewState = { + left: number; + top: number; + width: number; +}; + +function parseRatio(aspectRatio?: string) { + if (!aspectRatio || aspectRatio === 'long') return aspectRatio === 'long' ? 1 / 3 : 1; + const [w, h] = aspectRatio.split(':').map(Number); + return w && h ? w / h : 1; +} + +function nextPreviewState(event: PointerEvent, aspectRatio?: string): PreviewState { + const gap = 18; + const margin = 12; + const ratio = parseRatio(aspectRatio); + const width = Math.min(620, Math.max(280, window.innerWidth * 0.42)); + const height = Math.min(window.innerHeight * 0.82, width / ratio); + let left = event.clientX + gap; + let top = event.clientY + gap; + + if (left + width > window.innerWidth - margin) { + left = event.clientX - width - gap; + } + if (top + height > window.innerHeight - margin) { + top = window.innerHeight - height - margin; + } + return { + left: Math.max(margin, left), + top: Math.max(margin, top), + width, + }; +} + +export function HoverImagePreview({ + src, + alt, + imageClassName, + aspectRatio, +}: { + src: string; + alt: string; + imageClassName?: string; + aspectRatio?: string; +}) { + const [preview, setPreview] = useState(null); + + return ( + <> + {alt} { + if (event.pointerType === 'touch') return; + setPreview(nextPreviewState(event, aspectRatio)); + }} + onPointerLeave={() => setPreview(null)} + /> + {preview && ( +
+ +
+ )} + + ); +} diff --git a/src/components/PackPanel.tsx b/src/components/PackPanel.tsx index 3efeec6..ce4b6c1 100644 --- a/src/components/PackPanel.tsx +++ b/src/components/PackPanel.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from 'react'; import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types'; import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates'; +import { HoverImagePreview } from './HoverImagePreview'; const PACK_DESCRIPTIONS: Record = { patent: '六面视图 · 45° 立体图 · 局部放大', @@ -63,12 +64,12 @@ function AssetRow({ template, asset, accent, onRegenerate }: { {/* thumbnail */}
{ready ? ( - <> - {template.title} -
- -
- + ) : (
{template.aspectRatio} diff --git a/src/components/PromptPanel.tsx b/src/components/PromptPanel.tsx index c3f37d0..6dbc790 100644 --- a/src/components/PromptPanel.tsx +++ b/src/components/PromptPanel.tsx @@ -3,13 +3,14 @@ import { useRef, useState } from 'react'; import type { GenSession, ProjectInputMode, UploadedImage } from '@/lib/types'; -const PRESET_STYLES = [ - { id: 'plush', label: '毛绒玩偶' }, - { id: 'mecha', label: '机甲风' }, - { id: 'kawaii', label: '可爱萌系' }, - { id: 'blueprint', label: '专利蓝图' }, - { id: 'cyber', label: '赛博朋克' }, - { id: 'minimal', label: '极简' }, +const STYLE_OPTIONS = [ + { id: 'none', label: '无', value: '', preview: '/style-previews/none.png' }, + { id: 'plush', label: '毛绒玩偶', value: '毛绒玩偶', preview: '/style-previews/plush.png' }, + { id: 'mecha', label: '机甲风', value: '机甲风', preview: '/style-previews/mecha.png' }, + { id: 'kawaii', label: '可爱萌系', value: '可爱萌系', preview: '/style-previews/kawaii.png' }, + { id: 'blueprint', label: '专利蓝图', value: '专利蓝图', preview: '/style-previews/blueprint.png' }, + { id: 'cyber', label: '赛博朋克', value: '赛博朋克', preview: '/style-previews/cyber.png' }, + { id: 'minimal', label: '极简', value: '极简', preview: '/style-previews/minimal.png' }, ]; type UploadProjectFile = { @@ -173,37 +174,98 @@ export default function PromptPanel({ session, onGenerate, onUploadProject, load const replicatePreview = filePreview(replicateFile); const locked = Boolean(session); const activeTab = session ? tabFromMode(session.inputMode) : tab; + const canChooseStyle = !session && (activeTab === 'idea' || activeTab === 'remix'); return ( -
-
-
- Step · 01 · Input - · 想法 / 二创 / 复刻 -
-
- {[ - ['idea', '想法'], - ['remix', '二创'], - ['replicate', '复刻'], - ].map(([id, label]) => ( - - ))} -
-
+
+
+ + +
+ {session ? ( + + ) : activeTab === 'idea' && ( + <>
- - )} + + )} - {!session && activeTab === 'remix' && ( -
+ {!session && activeTab === 'remix' && ( +
-
- )} +
+ )} - {!session && activeTab === 'replicate' && ( -
+ {!session && activeTab === 'replicate' && ( +
-
- )} - - {!session && (activeTab === 'idea' || activeTab === 'remix') && ( -
- -
- - {PRESET_STYLES.map(s => ( - - ))} -
-
- )} - - {!session &&
- {(activeTab === 'idea' || activeTab === 'remix') ? ( -
- -
- {[4, 8, 12].map(n => ( - - ))}
-
- ) :
} - -
} + + {!session && ( +
+ +
+ )} +
+
); } diff --git a/src/components/ResultGrid.tsx b/src/components/ResultGrid.tsx index 1857d89..87fbdeb 100644 --- a/src/components/ResultGrid.tsx +++ b/src/components/ResultGrid.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import type { GenImage } from '@/lib/types'; +import { HoverImagePreview } from './HoverImagePreview'; export type ResultGridProps = { images: GenImage[]; @@ -56,10 +57,7 @@ export default function ResultGrid({ images, onAction }: ResultGridProps) { key={img.id} className={`tile group ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`} > - {`gen -
- -
+
{i + 1}
{img.status === 'selected' && (