From 4eda85ed667c786db63851bb9dd492d90109970b Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 18 May 2026 23:55:42 +0800 Subject: [PATCH] auto-save 2026-05-18 23:55 (+5, ~9) --- .env.local.example | 12 +- .memory/worklog.json | 13 + src/app/api/character/lock/route.ts | 84 +++++++ src/app/api/generate/route.ts | 6 +- src/app/api/packs/generate-all/route.ts | 59 +++++ src/app/api/packs/generate/route.ts | 83 +++++++ src/app/api/templates/route.ts | 27 +++ src/app/api/video/generate/route.ts | 18 ++ src/app/api/video/status/[taskId]/route.ts | 17 ++ src/app/page.tsx | 110 ++++++++- src/components/PackPanel.tsx | 57 ++++- src/lib/packGenerator.ts | 36 ++- src/lib/providers.ts | 97 +++++--- src/lib/templates.ts | 266 +++++++++++++++++++++ src/lib/types.ts | 43 +++- src/lib/videoProviders.ts | 87 +++++++ 16 files changed, 958 insertions(+), 57 deletions(-) create mode 100644 src/app/api/character/lock/route.ts create mode 100644 src/app/api/packs/generate-all/route.ts create mode 100644 src/app/api/packs/generate/route.ts create mode 100644 src/app/api/templates/route.ts create mode 100644 src/app/api/video/generate/route.ts create mode 100644 src/app/api/video/status/[taskId]/route.ts create mode 100644 src/lib/videoProviders.ts diff --git a/.env.local.example b/.env.local.example index ab99eea..e3c8eb2 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,2 +1,10 @@ -# 生图 API Key。没填则自动走 mock 模式(SVG 占位图) -POE_API_KEY= +# GPT 最高规格 API。没填 OPENAI_API_KEY 时图片/素材包生成走 mock 占位图。 +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 diff --git a/.memory/worklog.json b/.memory/worklog.json index 60d9fdd..a0753fa 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -130,6 +130,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 分支 master · 3 项未提交变更 · 最近提交:auto-save 2026-05-18 23:44 (+6, ~5)", "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 } ] } diff --git a/src/app/api/character/lock/route.ts b/src/app/api/character/lock/route.ts new file mode 100644 index 0000000..c1ef1f4 --- /dev/null +++ b/src/app/api/character/lock/route.ts @@ -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 }); + } +} diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index eb39491..23dc908 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -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 }); diff --git a/src/app/api/packs/generate-all/route.ts b/src/app/api/packs/generate-all/route.ts new file mode 100644 index 0000000..3715e09 --- /dev/null +++ b/src/app/api/packs/generate-all/route.ts @@ -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 }); + } +} diff --git a/src/app/api/packs/generate/route.ts b/src/app/api/packs/generate/route.ts new file mode 100644 index 0000000..bf6e674 --- /dev/null +++ b/src/app/api/packs/generate/route.ts @@ -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 }); + } +} diff --git a/src/app/api/templates/route.ts b/src/app/api/templates/route.ts new file mode 100644 index 0000000..dc2fe81 --- /dev/null +++ b/src/app/api/templates/route.ts @@ -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, + }); +} diff --git a/src/app/api/video/generate/route.ts b/src/app/api/video/generate/route.ts new file mode 100644 index 0000000..b9cf320 --- /dev/null +++ b/src/app/api/video/generate/route.ts @@ -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, + }); + } +} diff --git a/src/app/api/video/status/[taskId]/route.ts b/src/app/api/video/status/[taskId]/route.ts new file mode 100644 index 0000000..cf7ccf7 --- /dev/null +++ b/src/app/api/video/status/[taskId]/route.ts @@ -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, + }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 1616e51..be9dc04 100644 --- a/src/app/page.tsx +++ b/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([]); const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(false); const [loadingKind, setLoadingKind] = useState(null); + const [allLoading, setAllLoading] = useState(false); + const [characterLoading, setCharacterLoading] = useState(false); + const [videoLoading, setVideoLoading] = useState(false); const [provider, setProvider] = useState('?'); 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 (
- - - {provider === 'poe' ? 'Poe · 实时生图' : provider === 'mock' ? 'Mock · 占位图' : provider} + + + {provider === 'gpt' ? 'GPT · 最高规格' : provider === 'mock' ? 'Mock · 占位图' : provider}
@@ -128,7 +218,17 @@ export default function Home() { {current.id} - + )} diff --git a/src/components/PackPanel.tsx b/src/components/PackPanel.tsx index 0b22a4a..da9b086 100644 --- a/src/components/PackPanel.tsx +++ b/src/components/PackPanel.tsx @@ -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 = { patent: '六面视图、45 度立体图和局部放大,用于外观专利素材整理。', @@ -9,8 +9,6 @@ const PACK_DESCRIPTIONS: Record = { 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({

角色锁定与素材包

- 当前以第一个选中图作为主方案,生成结果会写回会话并生成 manifest。 + 当前以第一个选中图作为主方案,可先锁定角色,再全量生成三类素材包。

@@ -53,6 +63,23 @@ export default function PackPanel({
+
+ + +
+ {session.characterSpec && (
@@ -131,6 +158,28 @@ export default function PackPanel({ ))}
)} + +
+
+
Seedance 视频
+

+ 视频固定走 Seedance。这里先用当前主方案生成标准宣发/展示短片任务。 +

+
+
+ {VIDEO_TEMPLATES.map(template => ( + + ))} +
+
); } diff --git a/src/lib/packGenerator.ts b/src/lib/packGenerator.ts index 835629e..548274d 100644 --- a/src/lib/packGenerator.ts +++ b/src/lib/packGenerator.ts @@ -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 { + const fallback = buildFallbackCharacterSpec(session, sourceImage); + return generateGptJson({ + 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, diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 14964bb..805bc6e 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -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 { - 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 ![alt](url) 形式 - 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(opts: { + prompt: string; + fallback: T; +}): Promise { + 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; +} diff --git a/src/lib/templates.ts b/src/lib/templates.ts index 3d25e67..3dd9297 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -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 = { patent: '专利包', @@ -8,6 +9,43 @@ export const PACK_LABELS: Record = { 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 = { diff --git a/src/lib/types.ts b/src/lib/types.ts index c9786a5..e6d1cc5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; }; diff --git a/src/lib/videoProviders.ts b/src/lib/videoProviders.ts new file mode 100644 index 0000000..f727c2f --- /dev/null +++ b/src/lib/videoProviders.ts @@ -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 { + 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> = [{ 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 { + 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, + }; +}