auto-save 2026-05-18 23:55 (+5, ~9)

This commit is contained in:
2026-05-18 23:55:42 +08:00
parent a1b783cedb
commit 4eda85ed66
16 changed files with 958 additions and 57 deletions

View File

@@ -1,2 +1,10 @@
# 生图 API Key。没填则自动走 mock 模式SVG 占位图 # GPT 最高规格 API。没填 OPENAI_API_KEY 时图片/素材包生成走 mock 占位图
POE_API_KEY= OPENAI_API_KEY=
GPT_TEXT_MODEL=gpt-5.5
GPT_IMAGE_MODEL=gpt-image-1
GPT_API_BASE=https://api.openai.com/v1
# 视频生成固定走 Seedance。未配置 Key 时 /api/video/generate 返回 503。
SEEDANCE_API_KEY=
SEEDANCE_MODEL=seedance-1-0-pro
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3

View File

@@ -130,6 +130,19 @@
"type": "session-heartbeat", "type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 分支 master · 3 项未提交变更 · 最近提交auto-save 2026-05-18 23:44 (+6, ~5)", "message": "Claude 会话活跃 · 最近命令claude · 分支 master · 3 项未提交变更 · 最近提交auto-save 2026-05-18 23:44 (+6, ~5)",
"files_changed": 3 "files_changed": 3
},
{
"ts": "2026-05-18T23:50:17+08:00",
"type": "commit",
"message": "auto-save 2026-05-18 23:50 (~2, -1)",
"hash": "a1b783c",
"files_changed": 3
},
{
"ts": "2026-05-18T15:53:50Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 master · 2 项未提交变更 · 最近提交auto-save 2026-05-18 23:50 (~2, -1)",
"files_changed": 2
} }
] ]
} }

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import { buildCharacterSpec } from '@/lib/packGenerator';
import { detectProvider } from '@/lib/providers';
import { loadSession, saveSession } from '@/lib/storage';
import type { LockCharacterRequest, LockCharacterResponse } from '@/lib/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const { sessionId, imageId, force = false } = (await req.json()) as LockCharacterRequest;
if (!sessionId || !imageId) {
return NextResponse.json({ error: 'sessionId and imageId 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 (!force && session.characterSpec?.sourceImageId === imageId) {
const response: LockCharacterResponse = {
characterSpec: session.characterSpec,
provider: detectProvider(),
};
return NextResponse.json(response);
}
try {
const characterSpec = await buildCharacterSpec(session, sourceImage);
session.characterSpec = characterSpec;
await saveSession(session);
const response: LockCharacterResponse = {
characterSpec,
provider: detectProvider(),
};
return NextResponse.json(response);
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
import { NextResponse } from 'next/server';
import { buildCharacterSpec } from '@/lib/packGenerator';
import { detectProvider } from '@/lib/providers';
import { loadSession, saveSession } from '@/lib/storage';
import type { LockCharacterRequest, LockCharacterResponse } from '@/lib/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const { sessionId, imageId, force = false } = (await req.json()) as LockCharacterRequest;
if (!sessionId || !imageId) {
return NextResponse.json({ error: 'sessionId and imageId 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 (!force && session.characterSpec?.sourceImageId === imageId) {
return NextResponse.json({
characterSpec: session.characterSpec,
provider: detectProvider(),
} satisfies LockCharacterResponse);
}
try {
const characterSpec = await buildCharacterSpec(session, sourceImage);
session.characterSpec = characterSpec;
await saveSession(session);
return NextResponse.json({
characterSpec,
provider: detectProvider(),
} satisfies LockCharacterResponse);
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { detectProvider, generateMock, generatePoe } from '@/lib/providers'; import { detectProvider, generateGptImages, generateMock } from '@/lib/providers';
import { saveSession, saveGeneratedImage, saveRefImage } from '@/lib/storage'; import { saveSession, saveGeneratedImage, saveRefImage } from '@/lib/storage';
import type { GenerateRequest, GenerateResponse, GenSession } from '@/lib/types'; import type { GenerateRequest, GenerateResponse, GenSession } from '@/lib/types';
@@ -31,8 +31,8 @@ export async function POST(req: Request) {
let rawImages; let rawImages;
try { try {
rawImages = provider === 'poe' rawImages = provider === 'gpt'
? await generatePoe({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls }) ? await generateGptImages({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls })
: await generateMock({ sessionId, prompt: finalPrompt, count }); : await generateMock({ sessionId, prompt: finalPrompt, count });
} catch (e) { } catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 }); return NextResponse.json({ error: String(e) }, { status: 500 });

View File

@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { generateAssetPack } from '@/lib/packGenerator';
import { detectProvider } from '@/lib/providers';
import { loadSession, saveSession } from '@/lib/storage';
import { PACK_ORDER } from '@/lib/templates';
import type { GenerateAllPacksRequest, GenerateAllPacksResponse } from '@/lib/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const { sessionId, imageId } = (await req.json()) as GenerateAllPacksRequest;
if (!sessionId || !imageId) {
return NextResponse.json({ error: 'sessionId and imageId 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 packs = [];
const manifests = [];
let workingSession = session;
for (const kind of PACK_ORDER) {
const generated = await generateAssetPack({ session: workingSession, sourceImage, kind });
packs.push(generated.pack);
manifests.push(generated.manifest);
workingSession = {
...workingSession,
characterSpec: generated.pack.characterSpec,
packs: [
...(workingSession.packs ?? []).filter(existing => !(existing.kind === kind && existing.sourceImageId === imageId)),
generated.pack,
],
exports: [
...(workingSession.exports ?? []).filter(existing => !(existing.packKind === kind && existing.source.sourceImageId === imageId)),
generated.manifest,
],
};
}
await saveSession(workingSession);
return NextResponse.json({
packs,
manifests,
provider: detectProvider(),
} satisfies GenerateAllPacksResponse);
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,83 @@
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 });
try {
const { pack, manifest, provider } = await generateAssetPack({ session, sourceImage, kind });
const packs = (session.packs ?? []).filter(item => !(item.kind === kind && item.sourceImageId === imageId));
const exports = (session.exports ?? []).filter(item => !(item.packKind === kind && item.source.sourceImageId === imageId));
session.characterSpec = pack.characterSpec;
session.packs = [...packs, pack];
session.exports = [...exports, manifest];
await saveSession(session);
const response: GeneratePackResponse = { pack, manifest, provider };
return NextResponse.json(response);
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
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 { sessionId, imageId, kind } = (await req.json()) as GeneratePackRequest;
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.packKind === kind && existing.source.sourceImageId === imageId)),
manifest,
];
await saveSession(session);
return NextResponse.json({ pack, manifest, provider } satisfies GeneratePackResponse);
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import {
CHARACTER_SPEC_FIELDS,
FILENAME_SCHEMA,
PACK_LABELS,
PACK_ORDER,
PACK_TEMPLATES,
VIDEO_TEMPLATES,
} from '@/lib/templates';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET() {
return NextResponse.json({
filenameSchema: FILENAME_SCHEMA,
characterSpecFields: CHARACTER_SPEC_FIELDS,
packs: PACK_ORDER.map(kind => ({
kind,
label: PACK_LABELS[kind],
templates: PACK_TEMPLATES[kind],
requiredCount: PACK_TEMPLATES[kind].filter(template => template.required).length,
totalCount: PACK_TEMPLATES[kind].length,
})),
videos: VIDEO_TEMPLATES,
});
}

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { generateSeedanceVideo } from '@/lib/videoProviders';
import type { VideoGenerationRequest } from '@/lib/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const body = (await req.json()) as VideoGenerationRequest;
try {
return NextResponse.json(await generateSeedanceVideo(body));
} catch (error) {
const message = String(error);
return NextResponse.json({ error: message }, {
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
});
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { getSeedanceVideoTask } from '@/lib/videoProviders';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(_req: Request, ctx: { params: Promise<{ taskId: string }> }) {
const { taskId } = await ctx.params;
try {
return NextResponse.json(await getSeedanceVideoTask(taskId));
} catch (error) {
const message = String(error);
return NextResponse.json({ error: message }, {
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
});
}
}

View File

@@ -5,13 +5,25 @@ import PromptPanel from '@/components/PromptPanel';
import ResultGrid from '@/components/ResultGrid'; import ResultGrid from '@/components/ResultGrid';
import Sidebar from '@/components/Sidebar'; import Sidebar from '@/components/Sidebar';
import PackPanel from '@/components/PackPanel'; import PackPanel from '@/components/PackPanel';
import type { GenImage, GenSession, GeneratePackResponse, GenerateResponse, PackKind } from '@/lib/types'; import type {
GenImage,
GenSession,
GenerateAllPacksResponse,
GeneratePackResponse,
GenerateResponse,
LockCharacterResponse,
PackKind,
VideoGenerationResponse,
} from '@/lib/types';
export default function Home() { export default function Home() {
const [sessions, setSessions] = useState<GenSession[]>([]); const [sessions, setSessions] = useState<GenSession[]>([]);
const [current, setCurrent] = useState<GenSession | null>(null); const [current, setCurrent] = useState<GenSession | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingKind, setLoadingKind] = useState<PackKind | null>(null); const [loadingKind, setLoadingKind] = useState<PackKind | null>(null);
const [allLoading, setAllLoading] = useState(false);
const [characterLoading, setCharacterLoading] = useState(false);
const [videoLoading, setVideoLoading] = useState(false);
const [provider, setProvider] = useState<string>('?'); const [provider, setProvider] = useState<string>('?');
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -85,6 +97,84 @@ export default function Home() {
} }
} }
async function reloadCurrent(sessionId: string) {
const all = await refreshSessions();
const updated = all.find(x => x.id === sessionId) ?? null;
setCurrent(updated);
return updated;
}
async function handleLockCharacter(image: GenImage) {
if (!current || characterLoading) return;
setCharacterLoading(true);
try {
const r = await fetch('/api/character/lock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: current.id, imageId: image.id, force: true }),
});
if (!r.ok) {
alert('角色锁定失败:' + (await r.text()));
return;
}
const d: LockCharacterResponse = await r.json();
setProvider(d.provider);
await reloadCurrent(current.id);
} finally {
setCharacterLoading(false);
}
}
async function handleGenerateAll(image: GenImage) {
if (!current || allLoading) return;
setAllLoading(true);
try {
const r = await fetch('/api/packs/generate-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: current.id, imageId: image.id }),
});
if (!r.ok) {
alert('完整三包生成失败:' + (await r.text()));
return;
}
const d: GenerateAllPacksResponse = await r.json();
setProvider(d.provider);
await reloadCurrent(current.id);
} finally {
setAllLoading(false);
}
}
async function handleGenerateVideo(image: GenImage, promptTemplate: string) {
if (!current || videoLoading) return;
setVideoLoading(true);
try {
const character = current.characterSpec
? `${current.characterSpec.name}${current.characterSpec.oneLiner}`
: current.prompt;
const r = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: promptTemplate.replace('{character}', character),
imageUrl: image.url,
duration: 6,
ratio: '16:9',
resolution: '1080p',
}),
});
if (!r.ok) {
alert('Seedance 视频提交失败:' + (await r.text()));
return;
}
const d: VideoGenerationResponse = await r.json();
alert(`Seedance 任务已提交:${d.taskId ?? d.status}`);
} finally {
setVideoLoading(false);
}
}
return ( return (
<div className="flex h-screen bg-[#FAFAFA]"> <div className="flex h-screen bg-[#FAFAFA]">
<Sidebar <Sidebar
@@ -107,9 +197,9 @@ export default function Home() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<span className={provider === 'poe' ? 'chip chip-live' : 'chip chip-mock'}> <span className={provider === 'gpt' ? 'chip chip-live' : 'chip chip-mock'}>
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'poe' ? 'bg-emerald-500' : 'bg-amber-500'}`} /> <span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-500' : 'bg-amber-500'}`} />
{provider === 'poe' ? 'Poe · 实时生图' : provider === 'mock' ? 'Mock · 占位图' : provider} {provider === 'gpt' ? 'GPT · 最高规格' : provider === 'mock' ? 'Mock · 占位图' : provider}
</span> </span>
</div> </div>
</header> </header>
@@ -128,7 +218,17 @@ export default function Home() {
<code className="text-[11px] text-zinc-400 font-mono">{current.id}</code> <code className="text-[11px] text-zinc-400 font-mono">{current.id}</code>
</div> </div>
<ResultGrid images={current.images} onAction={handleAction} /> <ResultGrid images={current.images} onAction={handleAction} />
<PackPanel session={current} loadingKind={loadingKind} onGenerate={handleGeneratePack} /> <PackPanel
session={current}
loadingKind={loadingKind}
allLoading={allLoading}
characterLoading={characterLoading}
videoLoading={videoLoading}
onGenerate={handleGeneratePack}
onGenerateAll={handleGenerateAll}
onLockCharacter={handleLockCharacter}
onGenerateVideo={handleGenerateVideo}
/>
</section> </section>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import type { GenImage, GenSession, PackKind } from '@/lib/types'; import type { GenImage, GenSession, PackKind } from '@/lib/types';
import { PACK_LABELS } from '@/lib/templates'; import { PACK_LABELS, PACK_ORDER, VIDEO_TEMPLATES } from '@/lib/templates';
const PACK_DESCRIPTIONS: Record<PackKind, string> = { const PACK_DESCRIPTIONS: Record<PackKind, string> = {
patent: '六面视图、45 度立体图和局部放大,用于外观专利素材整理。', patent: '六面视图、45 度立体图和局部放大,用于外观专利素材整理。',
@@ -9,8 +9,6 @@ const PACK_DESCRIPTIONS: Record<PackKind, string> = {
marketing: '白底商品图、场景图、细节图和社媒图,用于新品宣发。', marketing: '白底商品图、场景图、细节图和社媒图,用于新品宣发。',
}; };
const PACK_ORDER: PackKind[] = ['patent', 'production', 'marketing'];
function manifestUrl(sessionId: string, kind: PackKind, version: string) { function manifestUrl(sessionId: string, kind: PackKind, version: string) {
return `/api/export/${sessionId}_${kind}_${version}_manifest.json`; return `/api/export/${sessionId}_${kind}_${version}_manifest.json`;
} }
@@ -18,11 +16,23 @@ function manifestUrl(sessionId: string, kind: PackKind, version: string) {
export default function PackPanel({ export default function PackPanel({
session, session,
loadingKind, loadingKind,
allLoading,
characterLoading,
videoLoading,
onGenerate, onGenerate,
onGenerateAll,
onLockCharacter,
onGenerateVideo,
}: { }: {
session: GenSession; session: GenSession;
loadingKind: PackKind | null; loadingKind: PackKind | null;
allLoading: boolean;
characterLoading: boolean;
videoLoading: boolean;
onGenerate: (image: GenImage, kind: PackKind) => void; onGenerate: (image: GenImage, kind: PackKind) => void;
onGenerateAll: (image: GenImage) => void;
onLockCharacter: (image: GenImage) => void;
onGenerateVideo: (image: GenImage, prompt: string) => void;
}) { }) {
const selectedImages = session.images.filter(image => image.status === 'selected'); const selectedImages = session.images.filter(image => image.status === 'selected');
const primaryImage = selectedImages[0] ?? null; const primaryImage = selectedImages[0] ?? null;
@@ -45,7 +55,7 @@ export default function PackPanel({
<div> <div>
<h2 className="text-sm font-semibold text-zinc-900"></h2> <h2 className="text-sm font-semibold text-zinc-900"></h2>
<p className="mt-1 text-xs text-zinc-500"> <p className="mt-1 text-xs text-zinc-500">
manifest
</p> </p>
</div> </div>
<div className="w-16 h-16 rounded-xl overflow-hidden ring-1 ring-zinc-200 bg-zinc-100 shrink-0"> <div className="w-16 h-16 rounded-xl overflow-hidden ring-1 ring-zinc-200 bg-zinc-100 shrink-0">
@@ -53,6 +63,23 @@ export default function PackPanel({
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => onLockCharacter(primaryImage)}
disabled={characterLoading || !!loadingKind || allLoading}
className="btn btn-outline text-xs"
>
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
</button>
<button
onClick={() => onGenerateAll(primaryImage)}
disabled={allLoading || !!loadingKind || characterLoading}
className="btn btn-primary text-xs"
>
{allLoading ? '全量生成中' : '一键生成完整三包'}
</button>
</div>
{session.characterSpec && ( {session.characterSpec && (
<div className="rounded-2xl bg-zinc-50 border border-zinc-200/70 p-4"> <div className="rounded-2xl bg-zinc-50 border border-zinc-200/70 p-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -131,6 +158,28 @@ export default function PackPanel({
))} ))}
</div> </div>
)} )}
<div className="rounded-2xl border border-zinc-200 p-4 bg-white space-y-3">
<div>
<div className="text-sm font-semibold text-zinc-900">Seedance </div>
<p className="text-[11px] text-zinc-500 mt-1">
Seedance/
</p>
</div>
<div className="grid grid-cols-4 gap-2">
{VIDEO_TEMPLATES.map(template => (
<button
key={template.id}
onClick={() => onGenerateVideo(primaryImage, template.promptTemplate)}
disabled={videoLoading}
className="btn btn-outline text-[11px] px-2 py-2"
title={template.description}
>
{videoLoading ? '提交中' : template.title}
</button>
))}
</div>
</div>
</section> </section>
); );
} }

View File

@@ -8,9 +8,9 @@ import type {
PackKind, PackKind,
ToyAsset, ToyAsset,
} from './types'; } from './types';
import { detectProvider, generateMock, generatePoe } from './providers'; import { detectProvider, generateGptImages, generateGptJson, generateMock } from './providers';
import { saveExportManifest, savePackImage } from './storage'; import { saveExportManifest, savePackImage } from './storage';
import { FILENAME_SCHEMA, getPackTemplates, PACK_LABELS, renderCharacterSummary } from './templates'; import { FILENAME_SCHEMA, getPackTemplates, PACK_LABELS, renderCharacterSummary, TEMPLATE_FREEZE_VERSION } from './templates';
function slugify(input: string): string { function slugify(input: string): string {
const ascii = input const ascii = input
@@ -28,7 +28,7 @@ function pickPalette(prompt: string): string[] {
return ['主色待定', '辅色待定', '强调色待定']; return ['主色待定', '辅色待定', '强调色待定'];
} }
export function buildCharacterSpec(session: GenSession, sourceImage: GenImage): CharacterSpec { function buildFallbackCharacterSpec(session: GenSession, sourceImage: GenImage): CharacterSpec {
const prompt = sourceImage.prompt || session.prompt; const prompt = sourceImage.prompt || session.prompt;
const name = prompt.includes('AI') ? 'AI 陪伴玩具' : '未命名玩具 IP'; const name = prompt.includes('AI') ? 'AI 陪伴玩具' : '未命名玩具 IP';
const materials = prompt.includes('毛绒') ? ['短毛绒/超柔面料', '刺绣五官', 'PP 棉填充'] : ['主体材料待定', '表面工艺待定']; const materials = prompt.includes('毛绒') ? ['短毛绒/超柔面料', '刺绣五官', 'PP 棉填充'] : ['主体材料待定', '表面工艺待定'];
@@ -57,6 +57,24 @@ export function buildCharacterSpec(session: GenSession, sourceImage: GenImage):
}; };
} }
export async function buildCharacterSpec(session: GenSession, sourceImage: GenImage): Promise<CharacterSpec> {
const fallback = buildFallbackCharacterSpec(session, sourceImage);
return generateGptJson<CharacterSpec>({
fallback,
prompt: [
'你是资深毛绒玩具产品经理、外观专利素材规划师和工厂打样顾问。',
'请根据用户 prompt 与选中图片 URL输出严格 JSON。不要 markdown不要解释。',
'字段必须完整匹配name, oneLiner, targetUser, speciesShape, bodyRatio, faceFeatures, colorPalette, materials, accessories, signatureElements, manufacturingNotes, patentFocus, marketingAngle, negativePrompt, sourceImageId, sourceImageUrl, lockedAt。',
'数组字段必须为字符串数组。lockedAt 使用给定时间戳。',
`当前时间戳:${Date.now()}`,
`用户 prompt${session.prompt}`,
`源图 ID${sourceImage.id}`,
`源图 URL${sourceImage.url}`,
`兜底 JSON${JSON.stringify(fallback)}`,
].join('\n'),
});
}
function renderPrompt(template: string, spec: CharacterSpec, sourceImageUrl: string): string { function renderPrompt(template: string, spec: CharacterSpec, sourceImageUrl: string): string {
return [ return [
template.replace('{character}', renderCharacterSummary(spec)), template.replace('{character}', renderCharacterSummary(spec)),
@@ -70,10 +88,10 @@ async function generateAssetImage(opts: {
assetId: string; assetId: string;
prompt: string; prompt: string;
sourceImageUrl: string; sourceImageUrl: string;
}): Promise<{ url: string; provider: 'mock' | 'poe'; raw?: unknown }> { }): Promise<{ url: string; provider: 'mock' | 'gpt'; raw?: unknown }> {
const provider = detectProvider(); const provider = detectProvider();
const images = provider === 'poe' const images = provider === 'gpt'
? await generatePoe({ ? await generateGptImages({
sessionId: `${opts.packId}_${opts.assetId}`, sessionId: `${opts.packId}_${opts.assetId}`,
prompt: opts.prompt, prompt: opts.prompt,
count: 1, count: 1,
@@ -110,11 +128,11 @@ export async function generateAssetPack(opts: {
session: GenSession; session: GenSession;
sourceImage: GenImage; sourceImage: GenImage;
kind: PackKind; kind: PackKind;
}): Promise<{ pack: AssetPack; manifest: ExportManifest; provider: 'mock' | 'poe' }> { }): Promise<{ pack: AssetPack; manifest: ExportManifest; provider: 'mock' | 'gpt' }> {
const templates = getPackTemplates(opts.kind); const templates = getPackTemplates(opts.kind);
const characterSpec = opts.session.characterSpec?.sourceImageId === opts.sourceImage.id const characterSpec = opts.session.characterSpec?.sourceImageId === opts.sourceImage.id
? opts.session.characterSpec ? opts.session.characterSpec
: buildCharacterSpec(opts.session, opts.sourceImage); : await buildCharacterSpec(opts.session, opts.sourceImage);
const version = 'v01'; const version = 'v01';
const packId = `pack_${opts.kind}_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`; const packId = `pack_${opts.kind}_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
const createdAt = Date.now(); const createdAt = Date.now();
@@ -147,6 +165,7 @@ export async function generateAssetPack(opts: {
meta: { meta: {
provider: generated.provider, provider: generated.provider,
packLabel: PACK_LABELS[opts.kind], packLabel: PACK_LABELS[opts.kind],
templateFreezeVersion: TEMPLATE_FREEZE_VERSION,
raw: generated.raw, raw: generated.raw,
}, },
}); });
@@ -175,6 +194,7 @@ export async function generateAssetPack(opts: {
version, version,
createdAt, createdAt,
filenameSchema: FILENAME_SCHEMA, filenameSchema: FILENAME_SCHEMA,
templateFreezeVersion: TEMPLATE_FREEZE_VERSION,
source: { source: {
prompt: opts.session.prompt, prompt: opts.session.prompt,
sourceImageId: opts.sourceImage.id, sourceImageId: opts.sourceImage.id,

View File

@@ -1,13 +1,13 @@
// 生图 provider 抽象。优先 Poe nano-banana-profeedback_image-gen-model
// 没 Key 时 mock。OpenRouter 当前模型 google/gemini-3.1-pro-preview 是文本模型,
// 不能生图,所以回退是 mock 不是 OpenRouter。
import type { GenImage } from './types'; import type { GenImage } from './types';
export type Provider = 'mock' | 'poe'; export type Provider = 'mock' | 'gpt';
export const GPT_TEXT_MODEL = process.env.GPT_TEXT_MODEL || 'gpt-5.5';
export const GPT_IMAGE_MODEL = process.env.GPT_IMAGE_MODEL || 'gpt-image-1';
const GPT_API_BASE = process.env.GPT_API_BASE || 'https://api.openai.com/v1';
export function detectProvider(): Provider { export function detectProvider(): Provider {
return process.env.POE_API_KEY ? 'poe' : 'mock'; return process.env.OPENAI_API_KEY ? 'gpt' : 'mock';
} }
// Mock返回 SVG 占位图data URL不真的生图 // Mock返回 SVG 占位图data URL不真的生图
@@ -43,49 +43,80 @@ export async function generateMock(opts: {
}); });
} }
// Poe nano-banana-pro 生图 function readImageBase64(payload: unknown): string {
export async function generatePoe(opts: { const data = payload as { data?: Array<{ b64_json?: string; url?: string }> };
const first = data.data?.[0];
if (!first) throw new Error('GPT image response missing data');
if (first.b64_json) return first.b64_json;
throw new Error('GPT image response missing b64_json');
}
export async function generateGptImages(opts: {
sessionId: string; sessionId: string;
prompt: string; prompt: string;
count: number; count: number;
refImages?: string[]; refImages?: string[];
}): Promise<GenImage[]> { }): Promise<GenImage[]> {
const key = process.env.POE_API_KEY; const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('POE_API_KEY missing'); if (!key) throw new Error('OPENAI_API_KEY missing');
const messages: Array<{ role: string; content: unknown }> = []; const refHint = opts.refImages?.length
if (opts.refImages && opts.refImages.length > 0) { ? `\n参考图 URL用于保持角色一致\n${opts.refImages.join('\n')}`
messages.push({ : '';
role: 'user',
content: [
{ type: 'text', text: opts.prompt },
...opts.refImages.map(url => ({ type: 'image_url', image_url: { url } })),
],
});
} else {
messages.push({ role: 'user', content: opts.prompt });
}
const calls = Array.from({ length: opts.count }).map(async (_, i) => { const calls = Array.from({ length: opts.count }).map(async (_, i) => {
const res = await fetch('https://api.poe.com/v1/chat/completions', { const res = await fetch(`${GPT_API_BASE}/images/generations`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, headers: {
body: JSON.stringify({ model: 'nano-banana-pro', messages, stream: false }), 'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify({
model: GPT_IMAGE_MODEL,
prompt: `${opts.prompt}${refHint}`,
n: 1,
size: '1024x1024',
response_format: 'b64_json',
}),
}); });
if (!res.ok) throw new Error(`Poe ${res.status}: ${await res.text()}`); if (!res.ok) throw new Error(`GPT image ${res.status}: ${await res.text()}`);
const data: { choices?: Array<{ message?: { content?: string } }> } = await res.json(); const data = await res.json();
const content = data.choices?.[0]?.message?.content || '';
// Poe 返回里图片通常以 markdown ![alt](url) 形式
const m = content.match(/!\[.*?\]\((https?:\/\/[^)]+)\)/) || content.match(/(https?:\/\/[^\s)]+\.(?:png|jpe?g|webp))/i);
const url = m ? m[1] : '';
return { return {
id: `img_${opts.sessionId}_${i}`, id: `img_${opts.sessionId}_${i}`,
url, url: `data:image/png;base64,${readImageBase64(data)}`,
prompt: opts.prompt, prompt: opts.prompt,
status: 'pending' as const, status: 'pending' as const,
meta: { provider: 'poe', index: i, raw: content.slice(0, 200) }, meta: { provider: 'gpt', model: GPT_IMAGE_MODEL, index: i },
}; };
}); });
return Promise.all(calls); return Promise.all(calls);
} }
export async function generateGptJson<T>(opts: {
prompt: string;
fallback: T;
}): Promise<T> {
const key = process.env.OPENAI_API_KEY;
if (!key) return opts.fallback;
const res = await fetch(`${GPT_API_BASE}/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify({
model: GPT_TEXT_MODEL,
input: opts.prompt,
text: { format: { type: 'json_object' } },
}),
});
if (!res.ok) throw new Error(`GPT text ${res.status}: ${await res.text()}`);
const data = await res.json() as {
output_text?: string;
output?: Array<{ content?: Array<{ text?: string }> }>;
};
const text = data.output_text || data.output?.flatMap(item => item.content ?? []).map(item => item.text ?? '').join('') || '';
return text.trim() ? JSON.parse(text) as T : opts.fallback;
}

View File

@@ -1,6 +1,7 @@
import type { AssetTemplate, CharacterSpec, PackKind } from './types'; import type { AssetTemplate, CharacterSpec, PackKind } from './types';
export const FILENAME_SCHEMA = '{sessionId}_{characterSlug}_{pack}_{view}_{version}.{ext}'; export const FILENAME_SCHEMA = '{sessionId}_{characterSlug}_{pack}_{view}_{version}.{ext}';
export const TEMPLATE_FREEZE_VERSION = 'toy-pack-templates-v01';
export const PACK_LABELS: Record<PackKind, string> = { export const PACK_LABELS: Record<PackKind, string> = {
patent: '专利包', patent: '专利包',
@@ -8,6 +9,43 @@ export const PACK_LABELS: Record<PackKind, string> = {
marketing: '宣发包', marketing: '宣发包',
}; };
export const PACK_ORDER: PackKind[] = ['patent', 'production', 'marketing'];
export const VIDEO_TEMPLATES = [
{
id: 'video_turntable',
title: '360 度旋转展示',
description: '用于电商和内部评审,展示整体体积、正背侧轮廓。',
duration: 6,
ratio: '16:9',
promptTemplate: '生成 360 度旋转展示视频:{character}. 白底或浅灰棚拍,镜头稳定,玩具缓慢旋转,展示正面、侧面、背面、顶部细节,真实毛绒质感。',
},
{
id: 'video_unboxing',
title: '开箱短片',
description: '用于新品宣发,展示包装到玩具出现的过程。',
duration: 8,
ratio: '9:16',
promptTemplate: '生成玩具开箱短片:{character}. 竖版社媒风格,从礼盒打开到玩具出现,温暖光线,突出礼物感和治愈感。',
},
{
id: 'video_touch_detail',
title: '触感细节',
description: '展示揉捏、毛绒、刺绣和配件细节。',
duration: 6,
ratio: '9:16',
promptTemplate: '生成玩具触感细节短片:{character}. 近景镜头,展示毛绒柔软、刺绣五官、配件细节和可抱触感,节奏轻柔。',
},
{
id: 'video_story_intro',
title: '角色故事介绍',
description: '用于 IP 设定和社媒发布。',
duration: 8,
ratio: '16:9',
promptTemplate: '生成玩具角色故事介绍视频:{character}. 轻剧情镜头,展示角色登场、表情、配件和性格气质,适合新品发布。',
},
] as const;
export const CHARACTER_SPEC_FIELDS: Array<{ key: keyof CharacterSpec; label: string; hint: string }> = [ export const CHARACTER_SPEC_FIELDS: Array<{ key: keyof CharacterSpec; label: string; hint: string }> = [
{ key: 'name', label: '名称', hint: '玩具/IP 名称' }, { key: 'name', label: '名称', hint: '玩具/IP 名称' },
{ key: 'oneLiner', label: '一句话定位', hint: '面向谁、解决什么情绪或场景' }, { key: 'oneLiner', label: '一句话定位', hint: '面向谁、解决什么情绪或场景' },
@@ -221,6 +259,30 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成毛绒玩具生产右侧打样图:{character}. 右侧正交视角,白底,带中文尺寸标注,确认侧面厚度、配件位置和非对称细节。', promptTemplate: '生成毛绒玩具生产右侧打样图:{character}. 右侧正交视角,白底,带中文尺寸标注,确认侧面厚度、配件位置和非对称细节。',
checklist: productionChecklist, checklist: productionChecklist,
}, },
{
id: 'prod_top_spec',
kind: 'production',
view: 'top_spec',
title: '俯视打样图',
description: '标注头顶结构、顶部配件和俯视轮廓。',
required: true,
aspectRatio: '1:1',
filenamePart: 'top-spec',
promptTemplate: '生成毛绒玩具生产俯视打样图:{character}. 从正上方观察,白底,带中文尺寸标注:头顶结构、耳朵/角/帽子位置、顶部配件、头部宽深比例。',
checklist: productionChecklist,
},
{
id: 'prod_bottom_spec',
kind: 'production',
view: 'bottom_spec',
title: '仰视打样图',
description: '标注脚底、防滑布、底部标签和底部结构。',
required: true,
aspectRatio: '1:1',
filenamePart: 'bottom-spec',
promptTemplate: '生成毛绒玩具生产仰视打样图:{character}. 从正下方观察,白底,带中文标注:脚底、防滑布、底部标签、底盘结构、坐姿稳定接触面。',
checklist: productionChecklist,
},
{ {
id: 'prod_dimension_overall', id: 'prod_dimension_overall',
kind: 'production', kind: 'production',
@@ -233,6 +295,30 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成毛绒玩具整体尺寸图:{character}. 白底,技术说明风格,标注总高、坐高、臂展、头身比、头宽、身体宽,单位 cm。', promptTemplate: '生成毛绒玩具整体尺寸图:{character}. 白底,技术说明风格,标注总高、坐高、臂展、头身比、头宽、身体宽,单位 cm。',
checklist: productionChecklist, checklist: productionChecklist,
}, },
{
id: 'prod_dimension_parts',
kind: 'production',
view: 'dimension_parts',
title: '部件尺寸图',
description: '标注耳朵、角、尾巴、包、配件和四肢尺寸。',
required: true,
aspectRatio: '4:5',
filenamePart: 'dimension-parts',
promptTemplate: '生成毛绒玩具部件尺寸图:{character}. 技术说明版式,拆出耳朵、角、尾巴、包、衣服/配件、四肢,标注长宽厚和数量,单位 cm小部件可用 mm。',
checklist: productionChecklist,
},
{
id: 'prod_scale_reference',
kind: 'production',
view: 'scale_reference',
title: '比例参考图',
description: '用手持或常见物辅助工厂理解尺寸。',
required: false,
aspectRatio: '4:5',
filenamePart: 'scale-reference',
promptTemplate: '生成玩具比例参考图:{character}. 手持或桌面常见物对比,只用于生产沟通,清楚表达 20cm/30cm/45cm 三档尺寸感,不加入营销海报元素。',
checklist: productionChecklist,
},
{ {
id: 'prod_material_board', id: 'prod_material_board',
kind: 'production', kind: 'production',
@@ -257,6 +343,42 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成毛绒玩具颜色板:{character}. 包含主色、辅色、强调色、五官色、衣服/配件色,给出 HEX 色值和建议 Pantone 占位,中文标注。', promptTemplate: '生成毛绒玩具颜色板:{character}. 包含主色、辅色、强调色、五官色、衣服/配件色,给出 HEX 色值和建议 Pantone 占位,中文标注。',
checklist: productionChecklist, checklist: productionChecklist,
}, },
{
id: 'prod_embroidery_detail',
kind: 'production',
view: 'embroidery_detail',
title: '刺绣印花细节',
description: '放大眼睛、嘴、腮红、图案和 Logo 工艺。',
required: true,
aspectRatio: '4:5',
filenamePart: 'embroidery-detail',
promptTemplate: '生成毛绒玩具刺绣/印花细节图:{character}. 放大眼睛、嘴巴、腮红、身体图案和标识,标注刺绣线色、印花边界、针距/线宽占位和工艺注意事项。',
checklist: productionChecklist,
},
{
id: 'prod_seam_map',
kind: 'production',
view: 'seam_map',
title: '缝线拼接图',
description: '标注头身、耳朵、四肢和配件连接方式。',
required: true,
aspectRatio: '4:5',
filenamePart: 'seam-map',
promptTemplate: '生成毛绒玩具缝线/拼接线图:{character}. 白底技术图风格,用虚线或彩色线标注头身连接、耳朵连接、四肢连接、配件固定点和开口返口位置,中文说明。',
checklist: productionChecklist,
},
{
id: 'prod_filling_spec',
kind: 'production',
view: 'filling_spec',
title: '填充说明图',
description: '说明软硬度、重心、坐姿稳定和填充区域。',
required: true,
aspectRatio: '4:5',
filenamePart: 'filling-spec',
promptTemplate: '生成毛绒玩具填充说明图:{character}. 剖面/分区技术说明风格,标注 PP 棉填充区域、软硬度、重心、坐姿稳定要求和不可过硬区域。',
checklist: productionChecklist,
},
{ {
id: 'prod_part_breakdown', id: 'prod_part_breakdown',
kind: 'production', kind: 'production',
@@ -269,6 +391,18 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成毛绒玩具拆件图:{character}. 技术图风格,白底,将头、身体、四肢、耳朵、服装、配件分开展示,带中文部件名称和数量标注。', promptTemplate: '生成毛绒玩具拆件图:{character}. 技术图风格,白底,将头、身体、四肢、耳朵、服装、配件分开展示,带中文部件名称和数量标注。',
checklist: productionChecklist, checklist: productionChecklist,
}, },
{
id: 'prod_accessory_sheet',
kind: 'production',
view: 'accessory_sheet',
title: '配件单页',
description: '独立展示帽子、背包、吊牌、可拆物等。',
required: false,
aspectRatio: '4:5',
filenamePart: 'accessory-sheet',
promptTemplate: '生成玩具配件单页:{character}. 将帽子、背包、吊牌、可拆物、电子件或装饰件独立展示,标注数量、材料、连接方式和尺寸建议,中文技术版式。',
checklist: productionChecklist,
},
{ {
id: 'prod_packaging_structure', id: 'prod_packaging_structure',
kind: 'production', kind: 'production',
@@ -368,6 +502,18 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成玩具面料触感宣发图:{character}. 微距商业摄影,突出毛绒质感、柔软填充、可抱触感,可加入简短中文卖点。', promptTemplate: '生成玩具面料触感宣发图:{character}. 微距商业摄影,突出毛绒质感、柔软填充、可抱触感,可加入简短中文卖点。',
checklist: marketingChecklist, checklist: marketingChecklist,
}, },
{
id: 'mkt_detail_accessory',
kind: 'marketing',
view: 'detail_accessory',
title: '配件卖点图',
description: '突出服装、配件、机关或标志性部件。',
required: true,
aspectRatio: '4:5',
filenamePart: 'detail-accessory',
promptTemplate: '生成玩具配件/服装/机关细节宣发图:{character}. 近景商业摄影,突出最具识别度的配件、服装、尾巴、背包或机械件,可加入简短卖点文案。',
checklist: marketingChecklist,
},
{ {
id: 'mkt_scene_bedroom', id: 'mkt_scene_bedroom',
kind: 'marketing', kind: 'marketing',
@@ -380,6 +526,30 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成玩具卧室陪伴场景图:{character}. 温暖床头或儿童房场景,柔和自然光,突出陪伴、治愈和礼物感。', promptTemplate: '生成玩具卧室陪伴场景图:{character}. 温暖床头或儿童房场景,柔和自然光,突出陪伴、治愈和礼物感。',
checklist: marketingChecklist, checklist: marketingChecklist,
}, },
{
id: 'mkt_scene_desk',
kind: 'marketing',
view: 'scene_desk',
title: '桌面陪伴场景',
description: '办公桌、学习桌或创作桌场景。',
required: true,
aspectRatio: '4:5',
filenamePart: 'scene-desk',
promptTemplate: '生成玩具桌面陪伴场景图:{character}. 办公桌/学习桌/创作桌场景,清爽自然光,突出日常陪伴、桌搭和收藏展示感。',
checklist: marketingChecklist,
},
{
id: 'mkt_scene_gift',
kind: 'marketing',
view: 'scene_gift',
title: '礼物开箱场景',
description: '礼物、节日、开箱和赠送场景。',
required: true,
aspectRatio: '4:5',
filenamePart: 'scene-gift',
promptTemplate: '生成玩具礼物/节日/开箱场景图:{character}. 温暖礼物氛围,展示包装、打开瞬间和产品露出,突出送礼、惊喜和治愈感。',
checklist: marketingChecklist,
},
{ {
id: 'mkt_size_lifestyle', id: 'mkt_size_lifestyle',
kind: 'marketing', kind: 'marketing',
@@ -392,6 +562,18 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成玩具尺寸对比宣发图:{character}. 手持或桌面生活方式场景,体现真实大小和可抱感,可加入尺寸文案占位。', promptTemplate: '生成玩具尺寸对比宣发图:{character}. 手持或桌面生活方式场景,体现真实大小和可抱感,可加入尺寸文案占位。',
checklist: marketingChecklist, checklist: marketingChecklist,
}, },
{
id: 'mkt_social_square',
kind: 'marketing',
view: 'social_square',
title: '社媒方形封面',
description: '社媒 1:1 封面图。',
required: false,
aspectRatio: '1:1',
filenamePart: 'social-square',
promptTemplate: '生成社媒 1:1 方形封面图:{character}. 强视觉中心,适合小红书/微博/朋友圈,留出短标题区域,产品不变形,整体风格与宣发包统一。',
checklist: marketingChecklist,
},
{ {
id: 'mkt_social_vertical', id: 'mkt_social_vertical',
kind: 'marketing', kind: 'marketing',
@@ -404,6 +586,30 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成竖版社媒封面图:{character}. 9:16适合小红书/短视频,强视觉中心,留出标题区域,产品不变形,风格统一。', promptTemplate: '生成竖版社媒封面图:{character}. 9:16适合小红书/短视频,强视觉中心,留出标题区域,产品不变形,风格统一。',
checklist: marketingChecklist, checklist: marketingChecklist,
}, },
{
id: 'mkt_packaging_render',
kind: 'marketing',
view: 'packaging_render',
title: '包装渲染图',
description: '展示包装外观、开窗、内托和礼盒质感。',
required: false,
aspectRatio: '4:5',
filenamePart: 'packaging-render',
promptTemplate: '生成玩具包装商业渲染图:{character}. 展示礼盒、开窗、内托、吊卡或说明卡,商业摄影质感,包装风格与角色一致,可加入品牌占位但不要真实商标。',
checklist: marketingChecklist,
},
{
id: 'mkt_character_story',
kind: 'marketing',
view: 'character_story',
title: '角色故事图',
description: '表达 IP 性格、故事和情绪价值。',
required: false,
aspectRatio: '4:5',
filenamePart: 'character-story',
promptTemplate: '生成 IP 角色故事宣发图:{character}. 结构化版式,包含角色名字、性格关键词、故事短句、陪伴场景和核心识别元素,中文文案占位。',
checklist: marketingChecklist,
},
{ {
id: 'mkt_ecommerce_longpage', id: 'mkt_ecommerce_longpage',
kind: 'marketing', kind: 'marketing',
@@ -416,6 +622,66 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
promptTemplate: '生成电商详情页长图模块:{character}. 结构化版式,包含角色故事、核心卖点、尺寸、材质、细节、包装展示,中文标题和短文案占位。', promptTemplate: '生成电商详情页长图模块:{character}. 结构化版式,包含角色故事、核心卖点、尺寸、材质、细节、包装展示,中文标题和短文案占位。',
checklist: marketingChecklist, checklist: marketingChecklist,
}, },
{
id: 'video_turntable',
kind: 'marketing',
view: 'video_turntable',
title: '360 度旋转视频脚本',
description: '电商 360 度转台展示分镜。',
required: false,
aspectRatio: '16:9',
filenamePart: 'video-turntable',
promptTemplate: '生成 360 度旋转展示短视频分镜板:{character}. 16:9包含 6 个镜头缩略画面和中文脚本:正面、左前 45 度、侧面、背面、右前 45 度、回到正面,适合电商展示。',
checklist: marketingChecklist,
},
{
id: 'video_unboxing',
kind: 'marketing',
view: 'video_unboxing',
title: '开箱视频脚本',
description: '包装到娃娃展示的开箱流程。',
required: false,
aspectRatio: '16:9',
filenamePart: 'video-unboxing',
promptTemplate: '生成玩具开箱短视频分镜板:{character}. 16:9包含包装特写、打开、取出、细节、抱持展示、结尾定格中文脚本和镜头时长占位。',
checklist: marketingChecklist,
},
{
id: 'video_touch_detail',
kind: 'marketing',
view: 'video_touch_detail',
title: '触感细节视频脚本',
description: '揉捏、触感和细节特写分镜。',
required: false,
aspectRatio: '16:9',
filenamePart: 'video-touch-detail',
promptTemplate: '生成玩具触感细节短视频分镜板:{character}. 16:9展示揉捏、毛绒触感、刺绣细节、配件互动和恢复形态中文脚本占位。',
checklist: marketingChecklist,
},
{
id: 'video_story_intro',
kind: 'marketing',
view: 'video_story_intro',
title: '角色设定短片脚本',
description: '社媒角色介绍短片分镜。',
required: false,
aspectRatio: '9:16',
filenamePart: 'video-story-intro',
promptTemplate: '生成竖版角色设定短片分镜板:{character}. 9:16适合社媒包含角色登场、性格关键词、陪伴场景、卖点定格和结尾 CTA中文脚本占位。',
checklist: marketingChecklist,
},
{
id: 'video_factory_preview',
kind: 'marketing',
view: 'video_factory_preview',
title: '工厂预览视频脚本',
description: '打样前内部沟通概念展示分镜。',
required: false,
aspectRatio: '16:9',
filenamePart: 'video-factory-preview',
promptTemplate: '生成工厂预览概念短片分镜板:{character}. 16:9面向内部沟通展示外观、尺寸、材料、拆件和包装要点中文说明占位不做消费者营销话术。',
checklist: marketingChecklist,
},
]; ];
export const PACK_TEMPLATES: Record<PackKind, AssetTemplate[]> = { export const PACK_TEMPLATES: Record<PackKind, AssetTemplate[]> = {

View File

@@ -28,7 +28,7 @@ export type GenerateRequest = {
export type GenerateResponse = { export type GenerateResponse = {
sessionId: string; sessionId: string;
images: GenImage[]; images: GenImage[];
provider: 'mock' | 'poe' | 'openrouter'; provider: 'mock' | 'gpt';
}; };
export type PackKind = 'patent' | 'production' | 'marketing'; export type PackKind = 'patent' | 'production' | 'marketing';
@@ -138,5 +138,44 @@ export type GeneratePackRequest = {
export type GeneratePackResponse = { export type GeneratePackResponse = {
pack: AssetPack; pack: AssetPack;
manifest: ExportManifest; manifest: ExportManifest;
provider: 'mock' | 'poe'; provider: 'mock' | 'gpt';
};
export type GenerateAllPacksRequest = {
sessionId: string;
imageId: string;
};
export type GenerateAllPacksResponse = {
packs: AssetPack[];
manifests: ExportManifest[];
provider: 'mock' | 'gpt';
};
export type LockCharacterRequest = {
sessionId: string;
imageId: string;
force?: boolean;
};
export type LockCharacterResponse = {
characterSpec: CharacterSpec;
provider: 'mock' | 'gpt';
};
export type VideoGenerationRequest = {
prompt: string;
imageUrl?: string;
duration?: number;
ratio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4';
resolution?: '720p' | '1080p';
};
export type VideoGenerationResponse = {
provider: 'seedance';
model: string;
taskId?: string;
status: 'submitted' | 'processing' | 'succeeded' | 'failed';
videoUrl?: string;
raw?: unknown;
}; };

87
src/lib/videoProviders.ts Normal file
View File

@@ -0,0 +1,87 @@
import type { VideoGenerationRequest, VideoGenerationResponse } from './types';
export const SEEDANCE_MODEL = process.env.SEEDANCE_MODEL || 'seedance-1-0-pro';
const SEEDANCE_API_BASE = process.env.SEEDANCE_API_BASE || 'https://ark.cn-beijing.volces.com/api/v3';
function durationOrDefault(duration?: number): number {
return Math.min(Math.max(duration ?? 6, 3), 10);
}
function normalizeStatus(status?: string): VideoGenerationResponse['status'] {
if (status === 'succeeded') return 'succeeded';
if (status === 'failed') return 'failed';
if (status === 'processing' || status === 'running') return 'processing';
return 'submitted';
}
export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promise<VideoGenerationResponse> {
const key = process.env.SEEDANCE_API_KEY;
if (!key) throw new Error('SEEDANCE_API_KEY missing');
if (!opts.prompt?.trim()) throw new Error('prompt required');
const content: Array<Record<string, unknown>> = [{ type: 'text', text: opts.prompt.trim() }];
if (opts.imageUrl) {
content.push({ type: 'image_url', image_url: { url: opts.imageUrl } });
}
const res = await fetch(`${SEEDANCE_API_BASE}/contents/generations/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify({
model: SEEDANCE_MODEL,
content,
duration: durationOrDefault(opts.duration),
ratio: opts.ratio || '16:9',
resolution: opts.resolution || '1080p',
}),
});
if (!res.ok) throw new Error(`Seedance ${res.status}: ${await res.text()}`);
const raw = await res.json() as {
id?: string;
task_id?: string;
status?: string;
video_url?: string;
output?: { video_url?: string; url?: string };
};
return {
provider: 'seedance',
model: SEEDANCE_MODEL,
taskId: raw.task_id || raw.id,
status: normalizeStatus(raw.status),
videoUrl: raw.video_url || raw.output?.video_url || raw.output?.url,
raw,
};
}
export async function getSeedanceVideoTask(taskId: string): Promise<VideoGenerationResponse> {
const key = process.env.SEEDANCE_API_KEY;
if (!key) throw new Error('SEEDANCE_API_KEY missing');
if (!taskId) throw new Error('taskId required');
const res = await fetch(`${SEEDANCE_API_BASE}/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!res.ok) throw new Error(`Seedance ${res.status}: ${await res.text()}`);
const raw = await res.json() as {
id?: string;
task_id?: string;
status?: string;
video_url?: string;
output?: { video_url?: string; url?: string };
};
return {
provider: 'seedance',
model: SEEDANCE_MODEL,
taskId: raw.task_id || raw.id || taskId,
status: normalizeStatus(raw.status),
videoUrl: raw.video_url || raw.output?.video_url || raw.output?.url,
raw,
};
}