diff --git a/src/app/api/text/generate/route.ts b/src/app/api/text/generate/route.ts new file mode 100644 index 0000000..7d53b10 --- /dev/null +++ b/src/app/api/text/generate/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { recordEvent } from '@/lib/auditDb'; +import { generateTextAssets } from '@/lib/textGenerator'; +import { loadSession, saveSession } from '@/lib/storage'; +import type { GenerateTextRequest, GenerateTextResponse } from '@/lib/types'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request) { + const body = (await req.json()) as GenerateTextRequest; + if (!body.sessionId) { + return NextResponse.json({ error: 'sessionId required' }, { status: 400 }); + } + + const session = await loadSession(body.sessionId); + if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); + if (!session.characterSpec) return NextResponse.json({ error: 'characterSpec required' }, { status: 409 }); + + try { + recordEvent({ + action: 'text.generate_started', + sessionId: session.id, + targetType: 'text', + status: 'started', + metadata: { templateIds: body.templateIds ?? null }, + }); + const generated = await generateTextAssets({ session, templateIds: body.templateIds }); + const nextIds = new Set(generated.textAssets.map(asset => asset.templateId)); + session.textAssets = [ + ...(session.textAssets ?? []).filter(asset => !nextIds.has(asset.templateId)), + ...generated.textAssets, + ]; + await saveSession(session); + recordEvent({ + action: 'text.generate_completed', + sessionId: session.id, + targetType: 'text', + status: 'ok', + provider: generated.provider, + metadata: { count: generated.textAssets.length }, + }); + + const response: GenerateTextResponse = { + textAssets: generated.textAssets, + provider: generated.provider, + }; + return NextResponse.json(response); + } catch (error) { + const message = String(error); + recordEvent({ + action: 'text.generate_failed', + sessionId: session.id, + targetType: 'text', + status: 'error', + message, + }); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/video/generate/route.ts b/src/app/api/video/generate/route.ts index e47702e..3ca5bf2 100644 --- a/src/app/api/video/generate/route.ts +++ b/src/app/api/video/generate/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from 'next/server'; import { recordEvent } from '@/lib/auditDb'; import { generateSeedanceVideo } from '@/lib/videoProviders'; -import type { VideoGenerationRequest } from '@/lib/types'; +import { loadSession, saveSession } from '@/lib/storage'; +import { VIDEO_TEMPLATES } from '@/lib/templates'; +import type { VideoGenerationRequest, VideoTask } from '@/lib/types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -9,13 +11,45 @@ export const dynamic = 'force-dynamic'; export async function POST(req: Request) { const body = (await req.json()) as VideoGenerationRequest; try { - recordEvent({ action: 'video.generate_started', targetType: 'video', status: 'started', provider: 'seedance', metadata: { ratio: body.ratio, duration: body.duration, hasImage: Boolean(body.imageUrl), refs: body.references?.length ?? 0 } }); + recordEvent({ action: 'video.generate_started', sessionId: body.sessionId, targetType: 'video', targetId: body.templateId, status: 'started', provider: 'seedance', metadata: { ratio: body.ratio, duration: body.duration, hasImage: Boolean(body.imageUrl), refs: body.references?.length ?? 0 } }); const response = await generateSeedanceVideo(body); - recordEvent({ action: 'video.generate_submitted', targetType: 'video', targetId: response.taskId ?? response.status, status: 'queued', provider: 'seedance', metadata: { status: response.status } }); - return NextResponse.json(response); + let task: VideoTask | undefined; + + if (body.sessionId && body.templateId) { + const session = await loadSession(body.sessionId); + if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); + const template = VIDEO_TEMPLATES.find(item => item.id === body.templateId); + const now = Date.now(); + task = { + id: `vid_${session.id}_${body.templateId}`, + templateId: body.templateId, + title: body.templateTitle || template?.title || body.templateId, + description: template?.description || '', + prompt: body.prompt, + anchorImageUrl: body.imageUrl, + provider: response.provider, + model: response.model, + taskId: response.taskId, + status: response.status, + videoUrl: response.videoUrl, + ratio: body.ratio || template?.ratio || '16:9', + duration: body.duration || template?.duration || 6, + submittedAt: now, + updatedAt: now, + raw: response.raw, + }; + session.videoTasks = [ + ...(session.videoTasks ?? []).filter(item => item.templateId !== body.templateId), + task, + ]; + await saveSession(session); + } + + recordEvent({ action: 'video.generate_submitted', sessionId: body.sessionId, targetType: 'video', targetId: response.taskId ?? body.templateId ?? response.status, status: 'queued', provider: 'seedance', metadata: { status: response.status, templateId: body.templateId } }); + return NextResponse.json({ ...response, task }); } catch (error) { const message = String(error); - recordEvent({ action: 'video.generate_failed', targetType: 'video', status: 'error', provider: 'seedance', message }); + recordEvent({ action: 'video.generate_failed', sessionId: body.sessionId, targetType: 'video', targetId: body.templateId, status: 'error', provider: 'seedance', message }); 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 index e5510e0..6884724 100644 --- a/src/app/api/video/status/[taskId]/route.ts +++ b/src/app/api/video/status/[taskId]/route.ts @@ -1,19 +1,46 @@ import { NextResponse } from 'next/server'; import { recordEvent } from '@/lib/auditDb'; import { getSeedanceVideoTask } from '@/lib/videoProviders'; +import { loadSession, saveSession } from '@/lib/storage'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET(_req: Request, ctx: { params: Promise<{ taskId: string }> }) { +export async function GET(req: Request, ctx: { params: Promise<{ taskId: string }> }) { const { taskId } = await ctx.params; + const sessionId = new URL(req.url).searchParams.get('sessionId')?.trim(); try { const response = await getSeedanceVideoTask(taskId); - recordEvent({ action: 'video.status_checked', targetType: 'video', targetId: taskId, status: 'ok', provider: 'seedance', metadata: { status: response.status } }); - return NextResponse.json(response); + let task = undefined; + + if (sessionId) { + const session = await loadSession(sessionId); + if (session?.videoTasks?.length) { + const index = session.videoTasks.findIndex(item => item.taskId === taskId); + if (index >= 0) { + task = { + ...session.videoTasks[index], + status: response.status, + videoUrl: response.videoUrl ?? session.videoTasks[index].videoUrl, + model: response.model, + updatedAt: Date.now(), + raw: response.raw, + }; + session.videoTasks = [ + ...session.videoTasks.slice(0, index), + task, + ...session.videoTasks.slice(index + 1), + ]; + await saveSession(session); + } + } + } + + recordEvent({ action: 'video.status_checked', sessionId, targetType: 'video', targetId: taskId, status: 'ok', provider: 'seedance', metadata: { status: response.status } }); + return NextResponse.json({ ...response, task }); } catch (error) { const message = String(error); - recordEvent({ action: 'video.status_failed', targetType: 'video', targetId: taskId, status: 'error', provider: 'seedance', message }); + recordEvent({ action: 'video.status_failed', sessionId, targetType: 'video', targetId: taskId, status: 'error', provider: 'seedance', message }); 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 2647168..6a49f76 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,6 +13,7 @@ import type { GenSession, GeneratePackResponse, GenerateResponse, + GenerateTextResponse, LockCharacterResponse, PackKind, ProjectFromUploadResponse, @@ -333,7 +334,8 @@ export default function Home() { const [loading, setLoading] = useState(false); const [loadingKind, setLoadingKind] = useState(null); const [characterLoading, setCharacterLoading] = useState(false); - const [videoLoading, setVideoLoading] = useState(false); + const [textLoading, setTextLoading] = useState(false); + const [videoLoading, setVideoLoading] = useState(null); const [uploadLoading, setUploadLoading] = useState(false); const [provider, setProvider] = useState('?'); const [sidebarOpen, setSidebarOpen] = useState(true); @@ -476,6 +478,20 @@ export default function Home() { }, 5000); } + function scheduleVideoRefresh(sessionId: string, taskId: string, remaining = 30) { + if (remaining <= 0) return; + window.setTimeout(async () => { + const r = await fetch(`/api/video/status/${encodeURIComponent(taskId)}?sessionId=${encodeURIComponent(sessionId)}`); + if (r.ok) { + const d: VideoGenerationResponse = await r.json(); + await reloadCurrent(sessionId); + if ((d.status === 'submitted' || d.status === 'processing') && remaining > 1) { + scheduleVideoRefresh(sessionId, taskId, remaining - 1); + } + } + }, 15000); + } + async function handleLockCharacter(image: GenImage) { if (!current || characterLoading) return; setCharacterLoading(true); @@ -508,9 +524,30 @@ export default function Home() { return { url: image.url, label: '意向图' }; } + async function handleGenerateText() { + if (!current || textLoading) return; + setTextLoading(true); + try { + const r = await fetch('/api/text/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: current.id }), + }); + if (!r.ok) { + alert('文字生成失败:' + (await r.text())); + return; + } + const d: GenerateTextResponse = await r.json(); + setProvider(d.provider); + await reloadCurrent(current.id); + } finally { + setTextLoading(false); + } + } + async function handleGenerateVideo(image: GenImage, template: typeof VIDEO_TEMPLATES[number]) { if (!current || videoLoading) return; - setVideoLoading(true); + setVideoLoading(template.id); try { const character = current.characterSpec ? `${current.characterSpec.name},${current.characterSpec.oneLiner}` @@ -521,6 +558,9 @@ export default function Home() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + sessionId: current.id, + templateId: template.id, + templateTitle: template.title, prompt, imageUrl: anchor.url, duration: template.duration, @@ -534,9 +574,25 @@ export default function Home() { return; } const d: VideoGenerationResponse = await r.json(); - alert(`Seedance 任务已提交:${d.taskId ?? d.status};参考:${anchor.label}`); + await reloadCurrent(current.id); + if (d.taskId) scheduleVideoRefresh(current.id, d.taskId); } finally { - setVideoLoading(false); + setVideoLoading(null); + } + } + + async function handleRefreshVideo(taskId: string) { + if (!current || videoLoading) return; + setVideoLoading(taskId); + try { + const r = await fetch(`/api/video/status/${encodeURIComponent(taskId)}?sessionId=${encodeURIComponent(current.id)}`); + if (!r.ok) { + alert('视频状态刷新失败:' + (await r.text())); + return; + } + await reloadCurrent(current.id); + } finally { + setVideoLoading(null); } } @@ -629,8 +685,11 @@ export default function Home() { session={current} activeNav={activeAssetPanel} onActiveNavChange={setActiveAssetPanel} + textLoading={textLoading} videoLoading={videoLoading} + onGenerateText={handleGenerateText} onGenerateVideo={handleGenerateVideo} + onRefreshVideo={handleRefreshVideo} /> void; +}) { const [showPromptId, setShowPromptId] = useState(null); + const textAssets = session.textAssets ?? []; + const byTemplate = new Map(textAssets.map(asset => [asset.templateId, asset])); + const completeCount = TEXT_TEMPLATES.filter(template => byTemplate.has(template.id)).length; return (
@@ -278,20 +291,31 @@ function TextTemplateSection({ locked }: { locked: boolean }) { 专利说明 · 工厂说明 · 宣发文案 · 视频脚本
-
- 0/{TEXT_TEMPLATES.length} +
+
+
+ {completeCount}/{TEXT_TEMPLATES.length}
GPT Text +
{TEXT_TEMPLATES.map(template => { const isOpen = showPromptId === template.id; + const asset = byTemplate.get(template.id); return ( -
+
text {template.outputFormat} @@ -305,6 +329,11 @@ function TextTemplateSection({ locked }: { locked: boolean }) { {template.required && 必备}

{template.description}

+ {asset && ( +
+                    {asset.content}
+                  
+ )}
{template.outputFormat} - 待生成 + + {asset ? '完成' : '待生成'} +
); @@ -333,13 +364,18 @@ function TextTemplateSection({ locked }: { locked: boolean }) { } /* ── Video Section ────────────────────────────── */ -function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: { - videoLoading: boolean; +function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateVideo, onRefreshVideo }: { + videoLoading: string | null; primaryImage: GenImage; locked: boolean; + session: GenSession; onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void; + onRefreshVideo: (taskId: string) => void; }) { const [showPromptId, setShowPromptId] = useState(null); + const videoTasks = session.videoTasks ?? []; + const byTemplate = new Map(videoTasks.map(task => [task.templateId, task])); + const submittedCount = VIDEO_TEMPLATES.filter(template => byTemplate.has(template.id)).length; return (
@@ -351,8 +387,10 @@ function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: { 异步任务 · 宣发 / 展示短片
-
- {VIDEO_TEMPLATES.length} 个模板 +
+
+
+ {submittedCount}/{VIDEO_TEMPLATES.length}
@@ -363,8 +401,10 @@ function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
{VIDEO_TEMPLATES.map(template => { const isOpen = showPromptId === template.id; + const task = byTemplate.get(template.id); + const loadingThis = videoLoading === template.id; return ( -
+
{template.ratio}

{template.description}

+ {task && ( +
+
+ {task.status} + {task.taskId && {task.taskId}} + {task.videoUrl && ( + + 打开视频 + + )} +
+
+ )}
); @@ -473,14 +526,20 @@ export default function PackPanel({ session, activeNav, onActiveNavChange, + textLoading, videoLoading, + onGenerateText, onGenerateVideo, + onRefreshVideo, }: { session: GenSession; activeNav: string; onActiveNavChange: (id: string) => void; - videoLoading: boolean; + textLoading: boolean; + videoLoading: string | null; + onGenerateText: () => void; onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void; + onRefreshVideo: (taskId: string) => void; }) { const selectedImages = session.images.filter(image => image.status === 'selected'); const primaryImage = selectedImages[0] ?? null; @@ -545,13 +604,20 @@ export default function PackPanel({
)} {activeNav === 'pack-text' ? ( - + ) : ( )}
diff --git a/src/lib/templates.ts b/src/lib/templates.ts index e297969..9f14217 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -19,7 +19,7 @@ export const VIDEO_TEMPLATES = [ description: '用于电商和内部评审,展示整体体积、正背侧轮廓。', duration: 6, ratio: '16:9', - promptTemplate: '生成 360 度旋转展示视频:{character}. 白底或浅灰棚拍,镜头稳定,玩具缓慢旋转,展示正面、侧面、背面、顶部细节,真实毛绒质感。', + promptTemplate: '生成 360 度旋转展示视频:{character}. 白底或浅灰棚拍,镜头稳定,潮流公仔缓慢旋转,展示正面、侧面、背面、顶部细节,强调 ABS/PVC 或搪胶玩具体块、高光黑面罩、耳机和配件结构。', }, { id: 'video_unboxing', @@ -27,15 +27,15 @@ export const VIDEO_TEMPLATES = [ description: '用于新品宣发,展示包装到玩具出现的过程。', duration: 8, ratio: '9:16', - promptTemplate: '生成玩具开箱短片:{character}. 竖版社媒风格,从礼盒打开到玩具出现,温暖光线,突出礼物感和治愈感。', + promptTemplate: '生成玩具开箱短片:{character}. 竖版社媒风格,从黑金礼盒打开到公仔出现,温暖但克制的棚拍光线,突出收藏感、街头音乐气质、面罩灯效和配件陈列。', }, { id: 'video_touch_detail', title: '触感细节', - description: '展示揉捏、毛绒、刺绣和配件细节。', + description: '展示面罩、耳机、服装纹理和配件细节。', duration: 6, ratio: '9:16', - promptTemplate: '生成玩具触感细节短片:{character}. 近景镜头,展示毛绒柔软、刺绣五官、配件细节和可抱触感,节奏轻柔。', + promptTemplate: '生成玩具细节短片:{character}. 近景镜头,展示高光黑声波面罩、白色头盔边框、针织帽纹理、耳机结构、链条、滑板和喷漆罐配件,节奏清楚,避免毛绒或布偶质感。', }, { id: 'video_story_intro', @@ -43,7 +43,7 @@ export const VIDEO_TEMPLATES = [ description: '用于 IP 设定和社媒发布。', duration: 8, ratio: '16:9', - promptTemplate: '生成玩具角色故事介绍视频:{character}. 轻剧情镜头,展示角色登场、表情、配件和性格气质,适合新品发布。', + promptTemplate: '生成玩具角色故事介绍视频:{character}. 轻剧情镜头,角色在街头音乐和滑板氛围中登场,展示声波面罩情绪变化、耳机、滑板、喷漆罐和黑金配色,适合新品发布。', }, { id: 'video_factory_preview', diff --git a/src/lib/textGenerator.ts b/src/lib/textGenerator.ts new file mode 100644 index 0000000..64c460b --- /dev/null +++ b/src/lib/textGenerator.ts @@ -0,0 +1,94 @@ +import { detectProvider, generateGptJson, GPT_TEXT_MODEL } from './providers'; +import { renderCharacterSummary, TEXT_TEMPLATES } from './templates'; +import type { GenSession, TextAsset, TextTemplate } from './types'; + +type TextGenerationJson = { + items?: Array<{ + templateId?: string; + content?: string; + }>; +}; + +function fallbackContent(template: TextTemplate, character: string) { + return [ + `# ${template.title}`, + '', + template.description, + '', + '## 角色基准', + character, + '', + '## 待完善', + '未配置文本模型时生成占位稿;请在 GPT 文本模型可用后重新生成。', + ].join('\n'); +} + +function normalizeContent(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +export async function generateTextAssets(opts: { + session: GenSession; + templateIds?: string[]; +}): Promise<{ textAssets: TextAsset[]; provider: 'mock' | 'gpt' }> { + if (!opts.session.characterSpec) throw new Error('characterSpec required'); + const allowed = new Set(opts.templateIds?.filter(Boolean) ?? TEXT_TEMPLATES.map(template => template.id)); + const templates = TEXT_TEMPLATES.filter(template => allowed.has(template.id)); + if (!templates.length) throw new Error('no text templates selected'); + + const character = renderCharacterSummary(opts.session.characterSpec); + const packSummary = (opts.session.packs ?? []) + .map(pack => `${pack.kind}: ${pack.assets.length} images`) + .join('; '); + const templateInstructions = templates.map(template => ({ + templateId: template.id, + title: template.title, + kind: template.kind, + outputFormat: template.outputFormat, + prompt: template.promptTemplate.replace('{character}', character), + })); + const fallback: TextGenerationJson = { + items: templates.map(template => ({ + templateId: template.id, + content: fallbackContent(template, character), + })), + }; + + const json = await generateGptJson({ + fallback, + prompt: [ + '你是玩具专利、工厂打样和宣发文案工作流助手。', + '请只输出 JSON,不要输出 Markdown 代码块。', + 'JSON 结构必须为 {"items":[{"templateId":"...","content":"..."}]}。', + 'content 可以是中文 Markdown 文本;表格类内容使用 Markdown 表格。', + '要求:围绕锁定角色设定,不改变角色外观;避免编造真实商标、认证编号、价格和法律结论;待确认项要明确标注。', + '', + `角色设定:\n${character}`, + `图片包进度:${packSummary || '暂无'}`, + '', + `请生成以下 ${templates.length} 个文案模板:`, + JSON.stringify(templateInstructions, null, 2), + ].join('\n'), + }); + + const byTemplate = new Map((json.items ?? []).map(item => [item.templateId, normalizeContent(item.content)])); + const now = Date.now(); + const provider = detectProvider(); + return { + provider, + textAssets: templates.map(template => ({ + id: `txt_${opts.session.id}_${template.id}`, + templateId: template.id, + kind: template.kind, + title: template.title, + description: template.description, + outputFormat: template.outputFormat, + content: byTemplate.get(template.id) || fallbackContent(template, character), + prompt: template.promptTemplate.replace('{character}', character), + status: 'complete', + provider, + model: provider === 'gpt' ? GPT_TEXT_MODEL : 'mock', + createdAt: now, + })), + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 4509715..711a1fd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -10,6 +10,8 @@ export type GenSession = { preFilledSlots?: PreFilledSlot[]; characterSpec?: CharacterSpec; packs?: AssetPack[]; + textAssets?: TextAsset[]; + videoTasks?: VideoTask[]; exports?: ExportManifest[]; }; @@ -118,6 +120,21 @@ export type TextTemplate = { checklist: string[]; }; +export type TextAsset = { + id: string; + templateId: string; + kind: PackKind | 'video' | 'project'; + title: string; + description: string; + outputFormat: TextTemplate['outputFormat']; + content: string; + prompt: string; + status: 'complete'; + provider: 'mock' | 'gpt'; + model: string; + createdAt: number; +}; + export type ToyAsset = { id: string; templateId: string; @@ -265,6 +282,9 @@ export type RegenerateAssetResponse = { }; export type VideoGenerationRequest = { + sessionId?: string; + templateId?: string; + templateTitle?: string; prompt: string; imageUrl?: string; references?: Array<{ @@ -287,3 +307,32 @@ export type VideoGenerationResponse = { videoUrl?: string; raw?: unknown; }; + +export type VideoTask = { + id: string; + templateId: string; + title: string; + description: string; + prompt: string; + anchorImageUrl?: string; + provider: 'seedance'; + model: string; + taskId?: string; + status: VideoGenerationResponse['status']; + videoUrl?: string; + ratio: NonNullable; + duration: number; + submittedAt: number; + updatedAt: number; + raw?: unknown; +}; + +export type GenerateTextRequest = { + sessionId: string; + templateIds?: string[]; +}; + +export type GenerateTextResponse = { + textAssets: TextAsset[]; + provider: 'mock' | 'gpt'; +}; diff --git a/src/lib/videoProviders.ts b/src/lib/videoProviders.ts index 48cf7d6..15bfecb 100644 --- a/src/lib/videoProviders.ts +++ b/src/lib/videoProviders.ts @@ -79,6 +79,7 @@ export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promi status?: string; video_url?: string; output?: { video_url?: string; url?: string }; + content?: { video_url?: string; url?: string }; }; return { @@ -86,7 +87,7 @@ export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promi model: SEEDANCE_MODEL, taskId: raw.task_id || raw.id, status: normalizeStatus(raw.status), - videoUrl: raw.video_url || raw.output?.video_url || raw.output?.url, + videoUrl: raw.video_url || raw.output?.video_url || raw.output?.url || raw.content?.video_url || raw.content?.url, raw, }; } @@ -107,6 +108,7 @@ export async function getSeedanceVideoTask(taskId: string): Promise