auto-save 2026-05-18 23:55 (+5, ~9)
This commit is contained in:
84
src/app/api/character/lock/route.ts
Normal file
84
src/app/api/character/lock/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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 type { GenerateRequest, GenerateResponse, GenSession } from '@/lib/types';
|
||||
|
||||
@@ -31,8 +31,8 @@ export async function POST(req: Request) {
|
||||
|
||||
let rawImages;
|
||||
try {
|
||||
rawImages = provider === 'poe'
|
||||
? await generatePoe({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls })
|
||||
rawImages = provider === 'gpt'
|
||||
? await generateGptImages({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls })
|
||||
: await generateMock({ sessionId, prompt: finalPrompt, count });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
|
||||
59
src/app/api/packs/generate-all/route.ts
Normal file
59
src/app/api/packs/generate-all/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
83
src/app/api/packs/generate/route.ts
Normal file
83
src/app/api/packs/generate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/templates/route.ts
Normal file
27
src/app/api/templates/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
18
src/app/api/video/generate/route.ts
Normal file
18
src/app/api/video/generate/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
17
src/app/api/video/status/[taskId]/route.ts
Normal file
17
src/app/api/video/status/[taskId]/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
110
src/app/page.tsx
110
src/app/page.tsx
@@ -5,13 +5,25 @@ import PromptPanel from '@/components/PromptPanel';
|
||||
import ResultGrid from '@/components/ResultGrid';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
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() {
|
||||
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 [allLoading, setAllLoading] = useState(false);
|
||||
const [characterLoading, setCharacterLoading] = useState(false);
|
||||
const [videoLoading, setVideoLoading] = useState(false);
|
||||
const [provider, setProvider] = useState<string>('?');
|
||||
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 (
|
||||
<div className="flex h-screen bg-[#FAFAFA]">
|
||||
<Sidebar
|
||||
@@ -107,9 +197,9 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className={provider === 'poe' ? 'chip chip-live' : 'chip chip-mock'}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'poe' ? 'bg-emerald-500' : 'bg-amber-500'}`} />
|
||||
{provider === 'poe' ? 'Poe · 实时生图' : provider === 'mock' ? 'Mock · 占位图' : provider}
|
||||
<span className={provider === 'gpt' ? 'chip chip-live' : 'chip chip-mock'}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-500' : 'bg-amber-500'}`} />
|
||||
{provider === 'gpt' ? 'GPT · 最高规格' : provider === 'mock' ? 'Mock · 占位图' : provider}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -128,7 +218,17 @@ 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} />
|
||||
<PackPanel
|
||||
session={current}
|
||||
loadingKind={loadingKind}
|
||||
allLoading={allLoading}
|
||||
characterLoading={characterLoading}
|
||||
videoLoading={videoLoading}
|
||||
onGenerate={handleGeneratePack}
|
||||
onGenerateAll={handleGenerateAll}
|
||||
onLockCharacter={handleLockCharacter}
|
||||
onGenerateVideo={handleGenerateVideo}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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> = {
|
||||
patent: '六面视图、45 度立体图和局部放大,用于外观专利素材整理。',
|
||||
@@ -9,8 +9,6 @@ const PACK_DESCRIPTIONS: Record<PackKind, string> = {
|
||||
marketing: '白底商品图、场景图、细节图和社媒图,用于新品宣发。',
|
||||
};
|
||||
|
||||
const PACK_ORDER: PackKind[] = ['patent', 'production', 'marketing'];
|
||||
|
||||
function manifestUrl(sessionId: string, kind: PackKind, version: string) {
|
||||
return `/api/export/${sessionId}_${kind}_${version}_manifest.json`;
|
||||
}
|
||||
@@ -18,11 +16,23 @@ function manifestUrl(sessionId: string, kind: PackKind, version: string) {
|
||||
export default function PackPanel({
|
||||
session,
|
||||
loadingKind,
|
||||
allLoading,
|
||||
characterLoading,
|
||||
videoLoading,
|
||||
onGenerate,
|
||||
onGenerateAll,
|
||||
onLockCharacter,
|
||||
onGenerateVideo,
|
||||
}: {
|
||||
session: GenSession;
|
||||
loadingKind: PackKind | null;
|
||||
allLoading: boolean;
|
||||
characterLoading: boolean;
|
||||
videoLoading: boolean;
|
||||
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 primaryImage = selectedImages[0] ?? null;
|
||||
@@ -45,7 +55,7 @@ export default function PackPanel({
|
||||
<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">
|
||||
@@ -53,6 +63,23 @@ export default function PackPanel({
|
||||
</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 && (
|
||||
<div className="rounded-2xl bg-zinc-50 border border-zinc-200/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -131,6 +158,28 @@ export default function PackPanel({
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
PackKind,
|
||||
ToyAsset,
|
||||
} from './types';
|
||||
import { detectProvider, generateMock, generatePoe } from './providers';
|
||||
import { detectProvider, generateGptImages, generateGptJson, generateMock } from './providers';
|
||||
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 {
|
||||
const ascii = input
|
||||
@@ -28,7 +28,7 @@ function pickPalette(prompt: string): string[] {
|
||||
return ['主色待定', '辅色待定', '强调色待定'];
|
||||
}
|
||||
|
||||
export function buildCharacterSpec(session: GenSession, sourceImage: GenImage): CharacterSpec {
|
||||
function buildFallbackCharacterSpec(session: GenSession, sourceImage: GenImage): CharacterSpec {
|
||||
const prompt = sourceImage.prompt || session.prompt;
|
||||
const name = prompt.includes('AI') ? 'AI 陪伴玩具' : '未命名玩具 IP';
|
||||
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 {
|
||||
return [
|
||||
template.replace('{character}', renderCharacterSummary(spec)),
|
||||
@@ -70,10 +88,10 @@ async function generateAssetImage(opts: {
|
||||
assetId: string;
|
||||
prompt: string;
|
||||
sourceImageUrl: string;
|
||||
}): Promise<{ url: string; provider: 'mock' | 'poe'; raw?: unknown }> {
|
||||
}): Promise<{ url: string; provider: 'mock' | 'gpt'; raw?: unknown }> {
|
||||
const provider = detectProvider();
|
||||
const images = provider === 'poe'
|
||||
? await generatePoe({
|
||||
const images = provider === 'gpt'
|
||||
? await generateGptImages({
|
||||
sessionId: `${opts.packId}_${opts.assetId}`,
|
||||
prompt: opts.prompt,
|
||||
count: 1,
|
||||
@@ -110,11 +128,11 @@ export async function generateAssetPack(opts: {
|
||||
session: GenSession;
|
||||
sourceImage: GenImage;
|
||||
kind: PackKind;
|
||||
}): Promise<{ pack: AssetPack; manifest: ExportManifest; provider: 'mock' | 'poe' }> {
|
||||
}): Promise<{ pack: AssetPack; manifest: ExportManifest; provider: 'mock' | 'gpt' }> {
|
||||
const templates = getPackTemplates(opts.kind);
|
||||
const characterSpec = opts.session.characterSpec?.sourceImageId === opts.sourceImage.id
|
||||
? opts.session.characterSpec
|
||||
: buildCharacterSpec(opts.session, opts.sourceImage);
|
||||
: await 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();
|
||||
@@ -147,6 +165,7 @@ export async function generateAssetPack(opts: {
|
||||
meta: {
|
||||
provider: generated.provider,
|
||||
packLabel: PACK_LABELS[opts.kind],
|
||||
templateFreezeVersion: TEMPLATE_FREEZE_VERSION,
|
||||
raw: generated.raw,
|
||||
},
|
||||
});
|
||||
@@ -175,6 +194,7 @@ export async function generateAssetPack(opts: {
|
||||
version,
|
||||
createdAt,
|
||||
filenameSchema: FILENAME_SCHEMA,
|
||||
templateFreezeVersion: TEMPLATE_FREEZE_VERSION,
|
||||
source: {
|
||||
prompt: opts.session.prompt,
|
||||
sourceImageId: opts.sourceImage.id,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// 生图 provider 抽象。优先 Poe nano-banana-pro(feedback_image-gen-model),
|
||||
// 没 Key 时 mock。OpenRouter 当前模型 google/gemini-3.1-pro-preview 是文本模型,
|
||||
// 不能生图,所以回退是 mock 不是 OpenRouter。
|
||||
|
||||
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 {
|
||||
return process.env.POE_API_KEY ? 'poe' : 'mock';
|
||||
return process.env.OPENAI_API_KEY ? 'gpt' : 'mock';
|
||||
}
|
||||
|
||||
// Mock:返回 SVG 占位图(data URL),不真的生图
|
||||
@@ -43,49 +43,80 @@ export async function generateMock(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
// Poe nano-banana-pro 生图
|
||||
export async function generatePoe(opts: {
|
||||
function readImageBase64(payload: unknown): string {
|
||||
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;
|
||||
prompt: string;
|
||||
count: number;
|
||||
refImages?: string[];
|
||||
}): Promise<GenImage[]> {
|
||||
const key = process.env.POE_API_KEY;
|
||||
if (!key) throw new Error('POE_API_KEY missing');
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) throw new Error('OPENAI_API_KEY missing');
|
||||
|
||||
const messages: Array<{ role: string; content: unknown }> = [];
|
||||
if (opts.refImages && opts.refImages.length > 0) {
|
||||
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 refHint = opts.refImages?.length
|
||||
? `\n参考图 URL,用于保持角色一致:\n${opts.refImages.join('\n')}`
|
||||
: '';
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
||||
body: JSON.stringify({ model: 'nano-banana-pro', messages, stream: false }),
|
||||
headers: {
|
||||
'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()}`);
|
||||
const data: { choices?: Array<{ message?: { content?: string } }> } = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content || '';
|
||||
// Poe 返回里图片通常以 markdown  形式
|
||||
const m = content.match(/!\[.*?\]\((https?:\/\/[^)]+)\)/) || content.match(/(https?:\/\/[^\s)]+\.(?:png|jpe?g|webp))/i);
|
||||
const url = m ? m[1] : '';
|
||||
if (!res.ok) throw new Error(`GPT image ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
id: `img_${opts.sessionId}_${i}`,
|
||||
url,
|
||||
url: `data:image/png;base64,${readImageBase64(data)}`,
|
||||
prompt: opts.prompt,
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AssetTemplate, CharacterSpec, PackKind } from './types';
|
||||
|
||||
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> = {
|
||||
patent: '专利包',
|
||||
@@ -8,6 +9,43 @@ export const PACK_LABELS: Record<PackKind, string> = {
|
||||
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 }> = [
|
||||
{ key: 'name', label: '名称', hint: '玩具/IP 名称' },
|
||||
{ key: 'oneLiner', label: '一句话定位', hint: '面向谁、解决什么情绪或场景' },
|
||||
@@ -221,6 +259,30 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成毛绒玩具生产右侧打样图:{character}. 右侧正交视角,白底,带中文尺寸标注,确认侧面厚度、配件位置和非对称细节。',
|
||||
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',
|
||||
kind: 'production',
|
||||
@@ -233,6 +295,30 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成毛绒玩具整体尺寸图:{character}. 白底,技术说明风格,标注总高、坐高、臂展、头身比、头宽、身体宽,单位 cm。',
|
||||
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',
|
||||
kind: 'production',
|
||||
@@ -257,6 +343,42 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成毛绒玩具颜色板:{character}. 包含主色、辅色、强调色、五官色、衣服/配件色,给出 HEX 色值和建议 Pantone 占位,中文标注。',
|
||||
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',
|
||||
kind: 'production',
|
||||
@@ -269,6 +391,18 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成毛绒玩具拆件图:{character}. 技术图风格,白底,将头、身体、四肢、耳朵、服装、配件分开展示,带中文部件名称和数量标注。',
|
||||
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',
|
||||
kind: 'production',
|
||||
@@ -368,6 +502,18 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成玩具面料触感宣发图:{character}. 微距商业摄影,突出毛绒质感、柔软填充、可抱触感,可加入简短中文卖点。',
|
||||
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',
|
||||
kind: 'marketing',
|
||||
@@ -380,6 +526,30 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成玩具卧室陪伴场景图:{character}. 温暖床头或儿童房场景,柔和自然光,突出陪伴、治愈和礼物感。',
|
||||
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',
|
||||
kind: 'marketing',
|
||||
@@ -392,6 +562,18 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成玩具尺寸对比宣发图:{character}. 手持或桌面生活方式场景,体现真实大小和可抱感,可加入尺寸文案占位。',
|
||||
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',
|
||||
kind: 'marketing',
|
||||
@@ -404,6 +586,30 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成竖版社媒封面图:{character}. 9:16,适合小红书/短视频,强视觉中心,留出标题区域,产品不变形,风格统一。',
|
||||
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',
|
||||
kind: 'marketing',
|
||||
@@ -416,6 +622,66 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
|
||||
promptTemplate: '生成电商详情页长图模块:{character}. 结构化版式,包含角色故事、核心卖点、尺寸、材质、细节、包装展示,中文标题和短文案占位。',
|
||||
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[]> = {
|
||||
|
||||
@@ -28,7 +28,7 @@ export type GenerateRequest = {
|
||||
export type GenerateResponse = {
|
||||
sessionId: string;
|
||||
images: GenImage[];
|
||||
provider: 'mock' | 'poe' | 'openrouter';
|
||||
provider: 'mock' | 'gpt';
|
||||
};
|
||||
|
||||
export type PackKind = 'patent' | 'production' | 'marketing';
|
||||
@@ -138,5 +138,44 @@ export type GeneratePackRequest = {
|
||||
export type GeneratePackResponse = {
|
||||
pack: AssetPack;
|
||||
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
87
src/lib/videoProviders.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user