auto-save 2026-05-18 23:44 (+6, ~5)
This commit is contained in:
@@ -104,6 +104,19 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-18 23:28 (+1, ~6)",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-18T23:39:25+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-18 23:39 (~2, -1)",
|
||||
"hash": "36fb4f9",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-18T15:43:50Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 4 项未提交变更 · 最近提交:auto-save 2026-05-18 23:39 (~2, -1)",
|
||||
"files_changed": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
26
src/app/api/export/[filename]/route.ts
Normal file
26
src/app/api/export/[filename]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ filename: string }> }) {
|
||||
const { filename } = await ctx.params;
|
||||
if (!filename.endsWith('.json') || filename.includes('..') || filename.includes('/')) {
|
||||
return NextResponse.json({ error: 'bad filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const file = path.join(process.cwd(), 'data', 'exports', filename);
|
||||
const raw = await fs.readFile(file, 'utf-8');
|
||||
return new NextResponse(raw, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,13 @@ export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ bucket: string; filename: string }> }) {
|
||||
const { bucket, filename } = await ctx.params;
|
||||
if (!['generated', 'selected', 'refs'].includes(bucket)) {
|
||||
if (!['generated', 'selected', 'refs', 'packs'].includes(bucket)) {
|
||||
return NextResponse.json({ error: 'bad bucket' }, { status: 400 });
|
||||
}
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
return NextResponse.json({ error: 'bad filename' }, { status: 400 });
|
||||
}
|
||||
const r = await readImageFile(bucket as 'generated' | 'selected' | 'refs', filename);
|
||||
const r = await readImageFile(bucket as 'generated' | 'selected' | 'refs' | 'packs', filename);
|
||||
if (!r) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||
return new NextResponse(new Uint8Array(r.buf), {
|
||||
headers: { 'Content-Type': r.type, 'Cache-Control': 'public, max-age=31536000, immutable' },
|
||||
|
||||
46
src/app/api/packs/generate/route.ts
Normal file
46
src/app/api/packs/generate/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { generateAssetPack } from '@/lib/packGenerator';
|
||||
import { loadSession, saveSession } from '@/lib/storage';
|
||||
import type { GeneratePackRequest, GeneratePackResponse, PackKind } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const PACK_KINDS: PackKind[] = ['patent', 'production', 'marketing'];
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = (await req.json()) as GeneratePackRequest;
|
||||
const { sessionId, imageId, kind } = body;
|
||||
|
||||
if (!sessionId || !imageId || !PACK_KINDS.includes(kind)) {
|
||||
return NextResponse.json({ error: 'sessionId, imageId and valid kind required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
||||
|
||||
const sourceImage = session.images.find(image => image.id === imageId);
|
||||
if (!sourceImage) return NextResponse.json({ error: 'image not found' }, { status: 404 });
|
||||
if (sourceImage.status !== 'selected') {
|
||||
return NextResponse.json({ error: 'image must be selected before generating packs' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { pack, manifest, provider } = await generateAssetPack({ session, sourceImage, kind });
|
||||
session.characterSpec = pack.characterSpec;
|
||||
session.packs = [
|
||||
...(session.packs ?? []).filter(existing => !(existing.kind === kind && existing.sourceImageId === imageId)),
|
||||
pack,
|
||||
];
|
||||
session.exports = [
|
||||
...(session.exports ?? []).filter(existing => existing.packId !== pack.id),
|
||||
manifest,
|
||||
];
|
||||
await saveSession(session);
|
||||
|
||||
const response: GeneratePackResponse = { pack, manifest, provider };
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import PromptPanel from '@/components/PromptPanel';
|
||||
import ResultGrid from '@/components/ResultGrid';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import type { GenImage, GenSession, GenerateResponse } from '@/lib/types';
|
||||
import PackPanel from '@/components/PackPanel';
|
||||
import type { GenImage, GenSession, GeneratePackResponse, GenerateResponse, PackKind } from '@/lib/types';
|
||||
|
||||
export default function Home() {
|
||||
const [sessions, setSessions] = useState<GenSession[]>([]);
|
||||
const [current, setCurrent] = useState<GenSession | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingKind, setLoadingKind] = useState<PackKind | null>(null);
|
||||
const [provider, setProvider] = useState<string>('?');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
@@ -60,6 +62,29 @@ export default function Home() {
|
||||
refreshSessions();
|
||||
}
|
||||
|
||||
async function handleGeneratePack(image: GenImage, kind: PackKind) {
|
||||
if (!current || loadingKind) return;
|
||||
setLoadingKind(kind);
|
||||
try {
|
||||
const r = await fetch('/api/packs/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: current.id, imageId: image.id, kind }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
alert('素材包生成失败:' + (await r.text()));
|
||||
return;
|
||||
}
|
||||
const d: GeneratePackResponse = await r.json();
|
||||
setProvider(d.provider);
|
||||
const all = await refreshSessions();
|
||||
const updated = all.find(x => x.id === current.id) ?? null;
|
||||
setCurrent(updated);
|
||||
} finally {
|
||||
setLoadingKind(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#FAFAFA]">
|
||||
<Sidebar
|
||||
@@ -103,6 +128,7 @@ export default function Home() {
|
||||
<code className="text-[11px] text-zinc-400 font-mono">{current.id}</code>
|
||||
</div>
|
||||
<ResultGrid images={current.images} onAction={handleAction} />
|
||||
<PackPanel session={current} loadingKind={loadingKind} onGenerate={handleGeneratePack} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
136
src/components/PackPanel.tsx
Normal file
136
src/components/PackPanel.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import type { GenImage, GenSession, PackKind } from '@/lib/types';
|
||||
import { PACK_LABELS } from '@/lib/templates';
|
||||
|
||||
const PACK_DESCRIPTIONS: Record<PackKind, string> = {
|
||||
patent: '六面视图、45 度立体图和局部放大,用于外观专利素材整理。',
|
||||
production: '尺寸、材料、颜色、拆件和包装结构,用于工厂报价与打样沟通。',
|
||||
marketing: '白底商品图、场景图、细节图和社媒图,用于新品宣发。',
|
||||
};
|
||||
|
||||
const PACK_ORDER: PackKind[] = ['patent', 'production', 'marketing'];
|
||||
|
||||
function manifestUrl(sessionId: string, kind: PackKind, version: string) {
|
||||
return `/api/export/${sessionId}_${kind}_${version}_manifest.json`;
|
||||
}
|
||||
|
||||
export default function PackPanel({
|
||||
session,
|
||||
loadingKind,
|
||||
onGenerate,
|
||||
}: {
|
||||
session: GenSession;
|
||||
loadingKind: PackKind | null;
|
||||
onGenerate: (image: GenImage, kind: PackKind) => void;
|
||||
}) {
|
||||
const selectedImages = session.images.filter(image => image.status === 'selected');
|
||||
const primaryImage = selectedImages[0] ?? null;
|
||||
const packs = session.packs ?? [];
|
||||
|
||||
if (!primaryImage) {
|
||||
return (
|
||||
<section className="card p-5">
|
||||
<h2 className="text-sm font-semibold text-zinc-900">下一步素材包</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
先在上方九宫格选中一个主方案,再生成专利、生产和宣发模板包。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card p-5 space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-zinc-900">角色锁定与素材包</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
当前以第一个选中图作为主方案,生成结果会写回会话并生成 manifest。
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-16 h-16 rounded-xl overflow-hidden ring-1 ring-zinc-200 bg-zinc-100 shrink-0">
|
||||
<img src={primaryImage.url} alt="selected source" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.characterSpec && (
|
||||
<div className="rounded-2xl bg-zinc-50 border border-zinc-200/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-zinc-900">{session.characterSpec.name}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1 line-clamp-2">{session.characterSpec.oneLiner}</div>
|
||||
</div>
|
||||
<span className="chip bg-white text-zinc-600 border border-zinc-200">CharacterSpec</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-zinc-600">
|
||||
<div>形态:{session.characterSpec.speciesShape}</div>
|
||||
<div>比例:{session.characterSpec.bodyRatio}</div>
|
||||
<div>配色:{session.characterSpec.colorPalette.join('、')}</div>
|
||||
<div>材料:{session.characterSpec.materials.join('、')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{PACK_ORDER.map(kind => {
|
||||
const pack = packs.find(item => item.kind === kind && item.sourceImageId === primaryImage.id);
|
||||
const isLoading = loadingKind === kind;
|
||||
return (
|
||||
<div key={kind} className="rounded-2xl border border-zinc-200 p-4 bg-white space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900">{PACK_LABELS[kind]}</div>
|
||||
<p className="text-[11px] text-zinc-500 mt-1 leading-relaxed">{PACK_DESCRIPTIONS[kind]}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onGenerate(primaryImage, kind)}
|
||||
disabled={!!loadingKind}
|
||||
className={pack ? 'btn btn-outline w-full text-xs' : 'btn btn-primary w-full text-xs'}
|
||||
>
|
||||
{isLoading ? '生成中' : pack ? '重新生成' : `生成${PACK_LABELS[kind]}`}
|
||||
</button>
|
||||
{pack && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
已生成 <span className="font-semibold text-zinc-900">{pack.assets.length}</span> 张 · {pack.version}
|
||||
</div>
|
||||
<a
|
||||
href={manifestUrl(session.id, kind, pack.version)}
|
||||
className="text-[11px] font-medium text-zinc-900 underline underline-offset-2"
|
||||
>
|
||||
下载 manifest JSON
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{packs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{packs.map(pack => (
|
||||
<div key={pack.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-zinc-900">{PACK_LABELS[pack.kind]} · {pack.assets.length} 张</h3>
|
||||
<code className="text-[10px] text-zinc-400">{pack.id}</code>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{pack.assets.map(asset => (
|
||||
<div key={asset.id} className="rounded-xl overflow-hidden border border-zinc-200 bg-zinc-50">
|
||||
<div className="aspect-square bg-zinc-100">
|
||||
<img src={asset.url} alt={asset.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div className="text-[11px] font-medium text-zinc-800 truncate">{asset.title}</div>
|
||||
<div className="text-[10px] text-zinc-400 truncate">{asset.view}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
212
src/lib/packGenerator.ts
Normal file
212
src/lib/packGenerator.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type {
|
||||
AssetPack,
|
||||
CharacterSpec,
|
||||
ExportManifest,
|
||||
GenImage,
|
||||
GenSession,
|
||||
PackKind,
|
||||
ToyAsset,
|
||||
} from './types';
|
||||
import { detectProvider, generateMock, generatePoe } from './providers';
|
||||
import { saveExportManifest, savePackImage } from './storage';
|
||||
import { FILENAME_SCHEMA, getPackTemplates, PACK_LABELS, renderCharacterSummary } from './templates';
|
||||
|
||||
function slugify(input: string): string {
|
||||
const ascii = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return ascii || 'toy';
|
||||
}
|
||||
|
||||
function pickPalette(prompt: string): string[] {
|
||||
const colors = ['橙白', '粉白', '蓝白', '黑金', '紫白', '绿色', '米色'];
|
||||
const found = colors.filter(c => prompt.includes(c));
|
||||
if (found.length > 0) return found;
|
||||
return ['主色待定', '辅色待定', '强调色待定'];
|
||||
}
|
||||
|
||||
export function buildCharacterSpec(session: GenSession, sourceImage: GenImage): CharacterSpec {
|
||||
const prompt = sourceImage.prompt || session.prompt;
|
||||
const name = prompt.includes('AI') ? 'AI 陪伴玩具' : '未命名玩具 IP';
|
||||
const materials = prompt.includes('毛绒') ? ['短毛绒/超柔面料', '刺绣五官', 'PP 棉填充'] : ['主体材料待定', '表面工艺待定'];
|
||||
const accessories = ['标志性配件待拆解', '吊牌/水洗标预留'];
|
||||
if (prompt.includes('机甲')) accessories.unshift('机甲头盔');
|
||||
if (prompt.includes('M logo') || prompt.includes('M logo'.toLowerCase())) accessories.unshift('胸前 M 标识');
|
||||
|
||||
return {
|
||||
name,
|
||||
oneLiner: prompt,
|
||||
targetUser: '潮玩/礼品/品牌联名用户',
|
||||
speciesShape: prompt.includes('机甲') ? '圆胖机甲陪伴娃娃' : '圆胖毛绒陪伴娃娃',
|
||||
bodyRatio: '大头圆胖体型,建议头身比 1:1.2,坐姿或轻微站姿均可打样',
|
||||
faceFeatures: '圆润亲和表情,眼睛、嘴巴、腮红需保持与源图一致',
|
||||
colorPalette: pickPalette(prompt),
|
||||
materials,
|
||||
accessories,
|
||||
signatureElements: ['圆胖轮廓', '高识别五官', '胸前/身体标志元素', '可被专利图清楚表达的配件轮廓'],
|
||||
manufacturingNotes: ['避免过细尖角和过多小件', '配件连接处需预留缝线或固定方式', '刺绣/印花边界需单独放大确认'],
|
||||
patentFocus: ['整体轮廓', '头身比例', '五官组合', '配件造型', '正背侧轮廓差异'],
|
||||
marketingAngle: ['AI 陪伴感', '治愈礼物属性', '可收藏 IP 角色', '柔软可抱材质'],
|
||||
negativePrompt: '不要改变角色五官、主配色、核心配件;不要加入无关品牌、水印、价格和营销贴纸',
|
||||
sourceImageId: sourceImage.id,
|
||||
sourceImageUrl: sourceImage.url,
|
||||
lockedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPrompt(template: string, spec: CharacterSpec, sourceImageUrl: string): string {
|
||||
return [
|
||||
template.replace('{character}', renderCharacterSummary(spec)),
|
||||
`参考源图:${sourceImageUrl}`,
|
||||
'必须保持同一角色身份、五官、配色、主体比例和核心配件一致。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function generateAssetImage(opts: {
|
||||
packId: string;
|
||||
assetId: string;
|
||||
prompt: string;
|
||||
sourceImageUrl: string;
|
||||
}): Promise<{ url: string; provider: 'mock' | 'poe'; raw?: unknown }> {
|
||||
const provider = detectProvider();
|
||||
const images = provider === 'poe'
|
||||
? await generatePoe({
|
||||
sessionId: `${opts.packId}_${opts.assetId}`,
|
||||
prompt: opts.prompt,
|
||||
count: 1,
|
||||
refImages: opts.sourceImageUrl.startsWith('http') ? [opts.sourceImageUrl] : [],
|
||||
})
|
||||
: await generateMock({
|
||||
sessionId: `${opts.packId}_${opts.assetId}`,
|
||||
prompt: opts.prompt,
|
||||
count: 1,
|
||||
});
|
||||
|
||||
const image = images[0];
|
||||
if (!image) throw new Error('pack asset generation failed');
|
||||
if (image.url.startsWith('data:')) {
|
||||
return { url: await savePackImage(opts.packId, opts.assetId, image.url), provider, raw: image.meta };
|
||||
}
|
||||
return { url: image.url, provider, raw: image.meta };
|
||||
}
|
||||
|
||||
function buildFilename(opts: {
|
||||
sessionId: string;
|
||||
characterName: string;
|
||||
kind: PackKind;
|
||||
view: string;
|
||||
version: string;
|
||||
url: string;
|
||||
}): string {
|
||||
const extMatch = opts.url.match(/\.([a-z0-9]+)(?:\?|$)/i);
|
||||
const ext = extMatch?.[1] || 'png';
|
||||
return `${opts.sessionId}_${slugify(opts.characterName)}_${opts.kind}_${opts.view}_${opts.version}.${ext}`;
|
||||
}
|
||||
|
||||
export async function generateAssetPack(opts: {
|
||||
session: GenSession;
|
||||
sourceImage: GenImage;
|
||||
kind: PackKind;
|
||||
}): Promise<{ pack: AssetPack; manifest: ExportManifest; provider: 'mock' | 'poe' }> {
|
||||
const templates = getPackTemplates(opts.kind);
|
||||
const characterSpec = opts.session.characterSpec?.sourceImageId === opts.sourceImage.id
|
||||
? opts.session.characterSpec
|
||||
: buildCharacterSpec(opts.session, opts.sourceImage);
|
||||
const version = 'v01';
|
||||
const packId = `pack_${opts.kind}_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
|
||||
const createdAt = Date.now();
|
||||
const provider = detectProvider();
|
||||
|
||||
const assets: ToyAsset[] = [];
|
||||
for (const template of templates) {
|
||||
const assetId = `${opts.kind}_${template.filenamePart}_${randomBytes(3).toString('hex')}`;
|
||||
const prompt = renderPrompt(template.promptTemplate, characterSpec, opts.sourceImage.url);
|
||||
const generated = await generateAssetImage({
|
||||
packId,
|
||||
assetId,
|
||||
prompt,
|
||||
sourceImageUrl: opts.sourceImage.url,
|
||||
});
|
||||
assets.push({
|
||||
id: assetId,
|
||||
templateId: template.id,
|
||||
kind: opts.kind,
|
||||
view: template.view,
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
url: generated.url,
|
||||
prompt,
|
||||
status: 'draft',
|
||||
version,
|
||||
aspectRatio: template.aspectRatio,
|
||||
required: template.required,
|
||||
createdAt: Date.now(),
|
||||
meta: {
|
||||
provider: generated.provider,
|
||||
packLabel: PACK_LABELS[opts.kind],
|
||||
raw: generated.raw,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const manifestId = `manifest_${packId}`;
|
||||
const pack: AssetPack = {
|
||||
id: packId,
|
||||
kind: opts.kind,
|
||||
sessionId: opts.session.id,
|
||||
sourceImageId: opts.sourceImage.id,
|
||||
sourceImageUrl: opts.sourceImage.url,
|
||||
characterSpec,
|
||||
assets,
|
||||
manifestId,
|
||||
createdAt,
|
||||
version,
|
||||
status: 'complete',
|
||||
};
|
||||
|
||||
const manifest: ExportManifest = {
|
||||
id: manifestId,
|
||||
sessionId: opts.session.id,
|
||||
packId,
|
||||
packKind: opts.kind,
|
||||
version,
|
||||
createdAt,
|
||||
filenameSchema: FILENAME_SCHEMA,
|
||||
source: {
|
||||
prompt: opts.session.prompt,
|
||||
sourceImageId: opts.sourceImage.id,
|
||||
sourceImageUrl: opts.sourceImage.url,
|
||||
},
|
||||
characterSpec,
|
||||
files: assets.map(asset => {
|
||||
const template = templates.find(t => t.id === asset.templateId);
|
||||
return {
|
||||
assetId: asset.id,
|
||||
templateId: asset.templateId,
|
||||
filename: buildFilename({
|
||||
sessionId: opts.session.id,
|
||||
characterName: characterSpec.name,
|
||||
kind: opts.kind,
|
||||
view: asset.view,
|
||||
version: asset.version,
|
||||
url: asset.url,
|
||||
}),
|
||||
url: asset.url,
|
||||
kind: asset.kind,
|
||||
view: asset.view,
|
||||
title: asset.title,
|
||||
required: asset.required,
|
||||
aspectRatio: asset.aspectRatio,
|
||||
prompt: asset.prompt,
|
||||
checklist: template?.checklist ?? [],
|
||||
};
|
||||
}),
|
||||
exportTargets: ['zip', 'pdf', 'manifest-json'],
|
||||
};
|
||||
|
||||
await saveExportManifest(manifest);
|
||||
return { pack, manifest, provider };
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { GenSession } from './types';
|
||||
import type { ExportManifest, GenSession } from './types';
|
||||
|
||||
const ROOT = path.join(process.cwd(), 'data');
|
||||
const SESS_DIR = path.join(ROOT, 'sessions');
|
||||
const SEL_DIR = path.join(ROOT, 'selected');
|
||||
const REF_DIR = path.join(ROOT, 'refs');
|
||||
const GEN_DIR = path.join(ROOT, 'generated');
|
||||
const PACK_DIR = path.join(ROOT, 'packs');
|
||||
const EXPORT_DIR = path.join(ROOT, 'exports');
|
||||
|
||||
async function ensureDirs() {
|
||||
await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR].map(d => fs.mkdir(d, { recursive: true })));
|
||||
await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true })));
|
||||
}
|
||||
|
||||
export async function saveSession(s: GenSession) {
|
||||
@@ -56,6 +58,16 @@ export async function saveGeneratedImage(sessionId: string, imageId: string, dat
|
||||
return `/api/img/generated/${sessionId}_${imageId}.${ext}`;
|
||||
}
|
||||
|
||||
export async function savePackImage(packId: string, assetId: string, dataUrl: string): Promise<string> {
|
||||
await ensureDirs();
|
||||
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!m) throw new Error('Invalid data URL');
|
||||
const ext = extFromMime(m[1]);
|
||||
const file = path.join(PACK_DIR, `${packId}_${assetId}.${ext}`);
|
||||
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
|
||||
return `/api/img/packs/${packId}_${assetId}.${ext}`;
|
||||
}
|
||||
|
||||
export async function copyToSelected(sessionId: string, imageId: string, srcUrl: string): Promise<string> {
|
||||
await ensureDirs();
|
||||
// srcUrl 形如 /api/img/generated/xxx.png
|
||||
@@ -68,9 +80,12 @@ export async function copyToSelected(sessionId: string, imageId: string, srcUrl:
|
||||
return `/api/img/selected/${filename}`;
|
||||
}
|
||||
|
||||
export async function readImageFile(bucket: 'generated' | 'selected' | 'refs', filename: string): Promise<{ buf: Buffer; type: string } | null> {
|
||||
export async function readImageFile(bucket: 'generated' | 'selected' | 'refs' | 'packs', filename: string): Promise<{ buf: Buffer; type: string } | null> {
|
||||
try {
|
||||
const dir = bucket === 'generated' ? GEN_DIR : bucket === 'selected' ? SEL_DIR : REF_DIR;
|
||||
const dir = bucket === 'generated' ? GEN_DIR
|
||||
: bucket === 'selected' ? SEL_DIR
|
||||
: bucket === 'refs' ? REF_DIR
|
||||
: PACK_DIR;
|
||||
const buf = await fs.readFile(path.join(dir, filename));
|
||||
const ext = path.extname(filename).slice(1).toLowerCase();
|
||||
const type = ext === 'jpg' ? 'image/jpeg'
|
||||
@@ -91,3 +106,10 @@ export async function saveRefImage(sessionId: string, idx: number, dataUrl: stri
|
||||
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
|
||||
return `/api/img/refs/${sessionId}_ref${idx}.${ext}`;
|
||||
}
|
||||
|
||||
export async function saveExportManifest(manifest: ExportManifest): Promise<string> {
|
||||
await ensureDirs();
|
||||
const filename = `${manifest.sessionId}_${manifest.packKind}_${manifest.version}_manifest.json`;
|
||||
await fs.writeFile(path.join(EXPORT_DIR, filename), JSON.stringify(manifest, null, 2), 'utf-8');
|
||||
return `/api/export/${filename}`;
|
||||
}
|
||||
|
||||
447
src/lib/templates.ts
Normal file
447
src/lib/templates.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import type { AssetTemplate, CharacterSpec, PackKind } from './types';
|
||||
|
||||
export const FILENAME_SCHEMA = '{sessionId}_{characterSlug}_{pack}_{view}_{version}.{ext}';
|
||||
|
||||
export const PACK_LABELS: Record<PackKind, string> = {
|
||||
patent: '专利包',
|
||||
production: '生产打样包',
|
||||
marketing: '宣发包',
|
||||
};
|
||||
|
||||
export const CHARACTER_SPEC_FIELDS: Array<{ key: keyof CharacterSpec; label: string; hint: string }> = [
|
||||
{ key: 'name', label: '名称', hint: '玩具/IP 名称' },
|
||||
{ key: 'oneLiner', label: '一句话定位', hint: '面向谁、解决什么情绪或场景' },
|
||||
{ key: 'targetUser', label: '目标用户', hint: '儿童、潮玩、礼品、品牌客户等' },
|
||||
{ key: 'speciesShape', label: '物种/形态', hint: '动物、人形、怪兽、机甲、食物拟人等' },
|
||||
{ key: 'bodyRatio', label: '身体比例', hint: '头身比、胖瘦、坐姿/站姿' },
|
||||
{ key: 'faceFeatures', label: '五官表情', hint: '眼睛、嘴巴、腮红、表情、眉毛' },
|
||||
{ key: 'colorPalette', label: '配色', hint: '主色、辅色、强调色、禁用色' },
|
||||
{ key: 'materials', label: '材料', hint: '毛绒、刺绣、印花、塑料件、织带、金属件' },
|
||||
{ key: 'accessories', label: '配件', hint: '衣服、帽子、背包、吊牌、电子件等' },
|
||||
{ key: 'signatureElements', label: '识别元素', hint: '最能形成识别度的 3-5 个元素' },
|
||||
{ key: 'manufacturingNotes', label: '生产提醒', hint: '避免过细尖角、过多小件等' },
|
||||
{ key: 'patentFocus', label: '专利重点', hint: '希望保护的外观特征' },
|
||||
{ key: 'marketingAngle', label: '营销角度', hint: '陪伴、治愈、收藏、科技感、礼物等' },
|
||||
{ key: 'negativePrompt', label: '禁忌项', hint: '不要出现的元素' },
|
||||
{ key: 'sourceImageId', label: '源图 ID', hint: '选中方案来源' },
|
||||
{ key: 'sourceImageUrl', label: '源图链接', hint: '选中方案来源' },
|
||||
{ key: 'lockedAt', label: '锁定时间', hint: '角色设定生成时间' },
|
||||
];
|
||||
|
||||
const patentChecklist = [
|
||||
'白底或浅灰底,产品居中',
|
||||
'无营销文案、场景道具、水印和价格信息',
|
||||
'五官、配色、配件位置与源图保持一致',
|
||||
'视角和比例清楚,外观重点可见',
|
||||
];
|
||||
|
||||
const productionChecklist = [
|
||||
'有清晰标注线和中文说明',
|
||||
'尺寸单位默认 cm,小部件可用 mm',
|
||||
'材料、工艺、颜色或结构信息足以支持工厂沟通',
|
||||
'不混入营销海报和场景背景',
|
||||
];
|
||||
|
||||
const marketingChecklist = [
|
||||
'角色与锁定设定一致',
|
||||
'画面明确服务一个渠道或卖点',
|
||||
'文案不遮挡产品关键外观',
|
||||
'镜头、色温、背景质感与同包素材统一',
|
||||
];
|
||||
|
||||
export const PATENT_TEMPLATES: AssetTemplate[] = [
|
||||
{
|
||||
id: 'patent_front',
|
||||
kind: 'patent',
|
||||
view: 'front',
|
||||
title: '主视图',
|
||||
description: '正面外观,是外观保护的核心视角。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'front',
|
||||
promptTemplate: '生成外观设计专利主视图:{character}. 正面正交视角,白底,产品居中,比例统一,无文字无水印无场景道具,清楚展示五官、身体比例、配色和配件。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_back',
|
||||
kind: 'patent',
|
||||
view: 'back',
|
||||
title: '后视图',
|
||||
description: '展示背部、后脑、尾巴、标签和后侧配件。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'back',
|
||||
promptTemplate: '生成外观设计专利后视图:{character}. 背面正交视角,白底,产品居中,比例与主视图一致,无文字无水印,清楚展示背部结构、尾巴、标签位和后侧配件。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_left',
|
||||
kind: 'patent',
|
||||
view: 'left',
|
||||
title: '左视图',
|
||||
description: '展示侧面厚度、鼻子凸起、耳朵和四肢外凸关系。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'left',
|
||||
promptTemplate: '生成外观设计专利左视图:{character}. 左侧正交视角,白底,比例统一,无文字无水印,突出厚度、耳朵角度、鼻子凸起、手脚位置。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_right',
|
||||
kind: 'patent',
|
||||
view: 'right',
|
||||
title: '右视图',
|
||||
description: '确认侧面对称性或非对称外观。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'right',
|
||||
promptTemplate: '生成外观设计专利右视图:{character}. 右侧正交视角,白底,比例统一,无文字无水印,确认侧面轮廓、配件和四肢关系。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_top',
|
||||
kind: 'patent',
|
||||
view: 'top',
|
||||
title: '俯视图',
|
||||
description: '展示头顶、耳朵、角、帽子或顶部装饰。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'top',
|
||||
promptTemplate: '生成外观设计专利俯视图:{character}. 从正上方观察,白底,产品居中,无文字无水印,展示头顶、耳朵、角、发饰、帽子和顶部轮廓。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_bottom',
|
||||
kind: 'patent',
|
||||
view: 'bottom',
|
||||
title: '仰视图',
|
||||
description: '展示脚底、底部标签、底盘和坐姿稳定结构。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'bottom',
|
||||
promptTemplate: '生成外观设计专利仰视图:{character}. 从正下方观察,白底,产品居中,无文字无水印,展示脚底、底部标签、底盘形态和底部轮廓。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_perspective_front',
|
||||
kind: 'patent',
|
||||
view: 'perspective_front',
|
||||
title: '前侧立体图',
|
||||
description: '45 度前侧展示整体体积和外观印象。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'perspective-front',
|
||||
promptTemplate: '生成外观设计专利前 45 度立体图:{character}. 白底,产品居中,无文字无水印,展示整体体积、正面五官、身体厚度和主要配件。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_perspective_back',
|
||||
kind: 'patent',
|
||||
view: 'perspective_back',
|
||||
title: '后侧立体图',
|
||||
description: '45 度后侧补充背部和体积信息。',
|
||||
required: false,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'perspective-back',
|
||||
promptTemplate: '生成外观设计专利后 45 度立体图:{character}. 白底,产品居中,无文字无水印,展示背面轮廓、尾巴、后侧配件和整体厚度。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_detail_face',
|
||||
kind: 'patent',
|
||||
view: 'detail_face',
|
||||
title: '脸部局部放大',
|
||||
description: '放大眼睛、嘴巴、腮红和表情细节。',
|
||||
required: false,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'detail-face',
|
||||
promptTemplate: '生成外观设计专利脸部局部放大图:{character}. 白底,无文字无水印,只放大脸部区域,清楚展示眼睛、嘴巴、腮红、表情和刺绣/印花边界。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
{
|
||||
id: 'patent_detail_accessory',
|
||||
kind: 'patent',
|
||||
view: 'detail_accessory',
|
||||
title: '配件局部放大',
|
||||
description: '放大服装、尾巴、背包或机械件等识别部件。',
|
||||
required: false,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'detail-accessory',
|
||||
promptTemplate: '生成外观设计专利配件局部放大图:{character}. 白底,无文字无水印,放大最具识别度的配件、服装、尾巴、背包或机械部件。',
|
||||
checklist: patentChecklist,
|
||||
},
|
||||
];
|
||||
|
||||
export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
|
||||
{
|
||||
id: 'prod_front_spec',
|
||||
kind: 'production',
|
||||
view: 'front_spec',
|
||||
title: '正面打样图',
|
||||
description: '标注总高、头宽、身体宽和手脚位置。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'front-spec',
|
||||
promptTemplate: '生成毛绒玩具生产正面打样图:{character}. 正面正交视角,白底,带清晰尺寸标注线和中文标注:总高、头宽、身体宽、手长、腿长、关键配件位置。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_back_spec',
|
||||
kind: 'production',
|
||||
view: 'back_spec',
|
||||
title: '背面打样图',
|
||||
description: '标注背部结构、尾巴、标签位和缝线。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'back-spec',
|
||||
promptTemplate: '生成毛绒玩具生产背面打样图:{character}. 背面正交视角,白底,带中文标注:后脑、背部拼接、尾巴、标签位、缝线和后侧配件。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_left_spec',
|
||||
kind: 'production',
|
||||
view: 'left_spec',
|
||||
title: '左侧打样图',
|
||||
description: '标注厚度、鼻子凸起、耳朵角度。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'left-spec',
|
||||
promptTemplate: '生成毛绒玩具生产左侧打样图:{character}. 左侧正交视角,白底,带中文尺寸标注:头部厚度、身体厚度、鼻子凸起、耳朵角度、四肢侧面位置。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_right_spec',
|
||||
kind: 'production',
|
||||
view: 'right_spec',
|
||||
title: '右侧打样图',
|
||||
description: '确认侧面对称性或非对称细节。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'right-spec',
|
||||
promptTemplate: '生成毛绒玩具生产右侧打样图:{character}. 右侧正交视角,白底,带中文尺寸标注,确认侧面厚度、配件位置和非对称细节。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_dimension_overall',
|
||||
kind: 'production',
|
||||
view: 'dimension_overall',
|
||||
title: '整体尺寸图',
|
||||
description: '标注总高、坐高、臂展和头身比。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'dimension-overall',
|
||||
promptTemplate: '生成毛绒玩具整体尺寸图:{character}. 白底,技术说明风格,标注总高、坐高、臂展、头身比、头宽、身体宽,单位 cm。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_material_board',
|
||||
kind: 'production',
|
||||
view: 'material_board',
|
||||
title: '材料工艺板',
|
||||
description: '说明面料、刺绣、印花、塑料件和填充。',
|
||||
required: true,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'material-board',
|
||||
promptTemplate: '生成毛绒玩具材料工艺板:{character}. 干净版式,包含面料样块、刺绣/印花说明、塑料件/织带/金属件说明、填充软硬度和坐姿稳定要求,中文标注。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_color_board',
|
||||
kind: 'production',
|
||||
view: 'color_board',
|
||||
title: '颜色板',
|
||||
description: '主色、辅色、五官、衣服和配件色。',
|
||||
required: true,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'color-board',
|
||||
promptTemplate: '生成毛绒玩具颜色板:{character}. 包含主色、辅色、强调色、五官色、衣服/配件色,给出 HEX 色值和建议 Pantone 占位,中文标注。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_part_breakdown',
|
||||
kind: 'production',
|
||||
view: 'part_breakdown',
|
||||
title: '拆件图',
|
||||
description: '拆分头、身体、四肢、耳朵、服装和配件。',
|
||||
required: true,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'part-breakdown',
|
||||
promptTemplate: '生成毛绒玩具拆件图:{character}. 技术图风格,白底,将头、身体、四肢、耳朵、服装、配件分开展示,带中文部件名称和数量标注。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_packaging_structure',
|
||||
kind: 'production',
|
||||
view: 'packaging_structure',
|
||||
title: '包装结构图',
|
||||
description: '礼盒、吊卡、内托和说明卡草案。',
|
||||
required: false,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'packaging-structure',
|
||||
promptTemplate: '生成玩具包装结构草图:{character}. 展示礼盒、吊卡、内托、说明卡和包装尺寸建议,干净白底,中文标注,适合给工厂沟通。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
{
|
||||
id: 'prod_label_tag',
|
||||
kind: 'production',
|
||||
view: 'label_tag',
|
||||
title: '标签吊牌图',
|
||||
description: '吊牌、水洗标和品牌标位置与内容草案。',
|
||||
required: false,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'label-tag',
|
||||
promptTemplate: '生成玩具标签吊牌说明图:{character}. 展示吊牌、水洗标、品牌标的位置和内容草案,包含尺寸建议和中文标注,白底技术版式。',
|
||||
checklist: productionChecklist,
|
||||
},
|
||||
];
|
||||
|
||||
export const MARKETING_TEMPLATES: AssetTemplate[] = [
|
||||
{
|
||||
id: 'mkt_hero_kv',
|
||||
kind: 'marketing',
|
||||
view: 'hero_kv',
|
||||
title: '主视觉 KV',
|
||||
description: '一眼讲清角色、气质和核心卖点。',
|
||||
required: true,
|
||||
aspectRatio: '16:9',
|
||||
filenamePart: 'hero-kv',
|
||||
promptTemplate: '生成玩具新品主视觉 KV:{character}. 高级商业摄影,干净但有情绪的背景,突出角色核心卖点,可加入简洁中文标题占位,但不要遮挡产品。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_white_front',
|
||||
kind: 'marketing',
|
||||
view: 'white_front',
|
||||
title: '白底正面商品图',
|
||||
description: '电商基础商品图。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'white-front',
|
||||
promptTemplate: '生成电商白底商品图:{character}. 正面,产品居中,柔和阴影,高清质感,无多余文字,适合主图。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_white_45',
|
||||
kind: 'marketing',
|
||||
view: 'white_45',
|
||||
title: '白底 45 度商品图',
|
||||
description: '展示体积和可爱感。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'white-45',
|
||||
promptTemplate: '生成电商白底 45 度商品图:{character}. 前侧 45 度,产品居中,柔和阴影,展示整体体积、毛绒质感和主要配件。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_white_back',
|
||||
kind: 'marketing',
|
||||
view: 'white_back',
|
||||
title: '白底背面商品图',
|
||||
description: '展示背部和后侧细节。',
|
||||
required: true,
|
||||
aspectRatio: '1:1',
|
||||
filenamePart: 'white-back',
|
||||
promptTemplate: '生成电商白底背面商品图:{character}. 背面,产品居中,柔和阴影,展示背部、尾巴、标签和后侧配件。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_detail_face',
|
||||
kind: 'marketing',
|
||||
view: 'detail_face',
|
||||
title: '脸部细节图',
|
||||
description: '展示五官、刺绣和表情。',
|
||||
required: true,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'detail-face',
|
||||
promptTemplate: '生成玩具脸部细节宣发图:{character}. 近景特写,突出眼睛、嘴巴、腮红、刺绣/印花工艺和治愈表情,可加入简短卖点文案。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_detail_material',
|
||||
kind: 'marketing',
|
||||
view: 'detail_material',
|
||||
title: '面料触感图',
|
||||
description: '展示毛绒、填充和触感卖点。',
|
||||
required: true,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'detail-material',
|
||||
promptTemplate: '生成玩具面料触感宣发图:{character}. 微距商业摄影,突出毛绒质感、柔软填充、可抱触感,可加入简短中文卖点。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_scene_bedroom',
|
||||
kind: 'marketing',
|
||||
view: 'scene_bedroom',
|
||||
title: '卧室陪伴场景',
|
||||
description: '床头、儿童房或治愈陪伴场景。',
|
||||
required: true,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'scene-bedroom',
|
||||
promptTemplate: '生成玩具卧室陪伴场景图:{character}. 温暖床头或儿童房场景,柔和自然光,突出陪伴、治愈和礼物感。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_size_lifestyle',
|
||||
kind: 'marketing',
|
||||
view: 'size_lifestyle',
|
||||
title: '尺寸对比图',
|
||||
description: '用手持、桌面或沙发对比表达尺寸。',
|
||||
required: true,
|
||||
aspectRatio: '4:5',
|
||||
filenamePart: 'size-lifestyle',
|
||||
promptTemplate: '生成玩具尺寸对比宣发图:{character}. 手持或桌面生活方式场景,体现真实大小和可抱感,可加入尺寸文案占位。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_social_vertical',
|
||||
kind: 'marketing',
|
||||
view: 'social_vertical',
|
||||
title: '竖版社媒封面',
|
||||
description: '小红书、短视频封面,3:4 或 9:16。',
|
||||
required: false,
|
||||
aspectRatio: '9:16',
|
||||
filenamePart: 'social-vertical',
|
||||
promptTemplate: '生成竖版社媒封面图:{character}. 9:16,适合小红书/短视频,强视觉中心,留出标题区域,产品不变形,风格统一。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
{
|
||||
id: 'mkt_ecommerce_longpage',
|
||||
kind: 'marketing',
|
||||
view: 'ecommerce_longpage',
|
||||
title: '电商详情页模块',
|
||||
description: '整合概念、尺寸、材质、卖点和包装。',
|
||||
required: false,
|
||||
aspectRatio: 'long',
|
||||
filenamePart: 'ecommerce-longpage',
|
||||
promptTemplate: '生成电商详情页长图模块:{character}. 结构化版式,包含角色故事、核心卖点、尺寸、材质、细节、包装展示,中文标题和短文案占位。',
|
||||
checklist: marketingChecklist,
|
||||
},
|
||||
];
|
||||
|
||||
export const PACK_TEMPLATES: Record<PackKind, AssetTemplate[]> = {
|
||||
patent: PATENT_TEMPLATES,
|
||||
production: PRODUCTION_TEMPLATES,
|
||||
marketing: MARKETING_TEMPLATES,
|
||||
};
|
||||
|
||||
export function getPackTemplates(kind: PackKind): AssetTemplate[] {
|
||||
return PACK_TEMPLATES[kind];
|
||||
}
|
||||
|
||||
export function renderCharacterSummary(spec: CharacterSpec): string {
|
||||
return [
|
||||
`${spec.name},${spec.oneLiner}`,
|
||||
`目标用户:${spec.targetUser}`,
|
||||
`形态:${spec.speciesShape}`,
|
||||
`比例:${spec.bodyRatio}`,
|
||||
`五官:${spec.faceFeatures}`,
|
||||
`配色:${spec.colorPalette.join('、')}`,
|
||||
`材料:${spec.materials.join('、')}`,
|
||||
`配件:${spec.accessories.join('、')}`,
|
||||
`识别元素:${spec.signatureElements.join('、')}`,
|
||||
`专利重点:${spec.patentFocus.join('、')}`,
|
||||
`营销角度:${spec.marketingAngle.join('、')}`,
|
||||
`不要出现:${spec.negativePrompt}`,
|
||||
].join(';');
|
||||
}
|
||||
|
||||
113
src/lib/types.ts
113
src/lib/types.ts
@@ -5,6 +5,9 @@ export type GenSession = {
|
||||
refImages: string[];
|
||||
count: number;
|
||||
images: GenImage[];
|
||||
characterSpec?: CharacterSpec;
|
||||
packs?: AssetPack[];
|
||||
exports?: ExportManifest[];
|
||||
};
|
||||
|
||||
export type GenImage = {
|
||||
@@ -27,3 +30,113 @@ export type GenerateResponse = {
|
||||
images: GenImage[];
|
||||
provider: 'mock' | 'poe' | 'openrouter';
|
||||
};
|
||||
|
||||
export type PackKind = 'patent' | 'production' | 'marketing';
|
||||
|
||||
export type AssetStatus = 'draft' | 'selected' | 'needs_regen' | 'approved' | 'exported';
|
||||
|
||||
export type CharacterSpec = {
|
||||
name: string;
|
||||
oneLiner: string;
|
||||
targetUser: string;
|
||||
speciesShape: string;
|
||||
bodyRatio: string;
|
||||
faceFeatures: string;
|
||||
colorPalette: string[];
|
||||
materials: string[];
|
||||
accessories: string[];
|
||||
signatureElements: string[];
|
||||
manufacturingNotes: string[];
|
||||
patentFocus: string[];
|
||||
marketingAngle: string[];
|
||||
negativePrompt: string;
|
||||
sourceImageId?: string;
|
||||
sourceImageUrl?: string;
|
||||
lockedAt: number;
|
||||
};
|
||||
|
||||
export type AssetTemplate = {
|
||||
id: string;
|
||||
kind: PackKind;
|
||||
view: string;
|
||||
title: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
aspectRatio: '1:1' | '3:4' | '4:5' | '9:16' | '16:9' | 'long';
|
||||
filenamePart: string;
|
||||
promptTemplate: string;
|
||||
checklist: string[];
|
||||
};
|
||||
|
||||
export type ToyAsset = {
|
||||
id: string;
|
||||
templateId: string;
|
||||
kind: PackKind;
|
||||
view: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
status: AssetStatus;
|
||||
version: string;
|
||||
aspectRatio: AssetTemplate['aspectRatio'];
|
||||
required: boolean;
|
||||
createdAt: number;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AssetPack = {
|
||||
id: string;
|
||||
kind: PackKind;
|
||||
sessionId: string;
|
||||
sourceImageId: string;
|
||||
sourceImageUrl: string;
|
||||
characterSpec: CharacterSpec;
|
||||
assets: ToyAsset[];
|
||||
manifestId: string;
|
||||
createdAt: number;
|
||||
version: string;
|
||||
status: 'draft' | 'complete';
|
||||
};
|
||||
|
||||
export type ExportManifest = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
packId: string;
|
||||
packKind: PackKind;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
filenameSchema: string;
|
||||
source: {
|
||||
prompt: string;
|
||||
sourceImageId: string;
|
||||
sourceImageUrl: string;
|
||||
};
|
||||
characterSpec: CharacterSpec;
|
||||
files: Array<{
|
||||
assetId: string;
|
||||
templateId: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
kind: PackKind;
|
||||
view: string;
|
||||
title: string;
|
||||
required: boolean;
|
||||
aspectRatio: AssetTemplate['aspectRatio'];
|
||||
prompt: string;
|
||||
checklist: string[];
|
||||
}>;
|
||||
exportTargets: Array<'zip' | 'pdf' | 'manifest-json'>;
|
||||
};
|
||||
|
||||
export type GeneratePackRequest = {
|
||||
sessionId: string;
|
||||
imageId: string;
|
||||
kind: PackKind;
|
||||
};
|
||||
|
||||
export type GeneratePackResponse = {
|
||||
pack: AssetPack;
|
||||
manifest: ExportManifest;
|
||||
provider: 'mock' | 'poe';
|
||||
};
|
||||
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user