feat: persist text and video outputs

This commit is contained in:
2026-05-20 21:20:41 +08:00
parent 0869c7402b
commit 765744d25c
9 changed files with 426 additions and 35 deletions

View File

@@ -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 });
}
}

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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<PackKind | null>(null);
const [characterLoading, setCharacterLoading] = useState(false);
const [videoLoading, setVideoLoading] = useState(false);
const [textLoading, setTextLoading] = useState(false);
const [videoLoading, setVideoLoading] = useState<string | null>(null);
const [uploadLoading, setUploadLoading] = useState(false);
const [provider, setProvider] = useState<string>('?');
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}
/>
</section>
<ProjectGalleryDrawer

View File

@@ -265,8 +265,21 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex }: {
}
/* ── Text Template Section ────────────────────── */
function TextTemplateSection({ locked }: { locked: boolean }) {
function TextTemplateSection({
locked,
session,
loading,
onGenerateText,
}: {
locked: boolean;
session: GenSession;
loading: boolean;
onGenerateText: () => void;
}) {
const [showPromptId, setShowPromptId] = useState<string | null>(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 (
<section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-text" aria-disabled={locked}>
@@ -278,20 +291,31 @@ function TextTemplateSection({ locked }: { locked: boolean }) {
<span className="text-[10px] text-white/35"> · · · </span>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 rounded-full bg-white/[0.06]" />
<span className="text-[10px] font-mono text-white/35 shrink-0">0/{TEXT_TEMPLATES.length}</span>
<div className="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div className="h-full bg-gradient-to-r from-[#8cb478] to-[#d6b36a]" style={{ width: `${Math.round((completeCount / TEXT_TEMPLATES.length) * 100)}%` }} />
</div>
<span className="text-[10px] font-mono text-white/35 shrink-0">{completeCount}/{TEXT_TEMPLATES.length}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="chip bg-[#8cb478]/15 text-[#cfe7a7] border-[#8cb478]/30 text-[10px]">GPT Text</span>
<button
type="button"
onClick={onGenerateText}
disabled={locked || loading}
className="btn btn-primary px-3 py-1.5 text-[11px] disabled:opacity-40"
>
{loading ? '生成中' : completeCount ? '刷新文字' : '生成文字'}
</button>
</div>
</div>
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
{TEXT_TEMPLATES.map(template => {
const isOpen = showPromptId === template.id;
const asset = byTemplate.get(template.id);
return (
<div key={template.id} className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div key={template.id} className="grid grid-cols-[72px_minmax(0,1fr)_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#8cb478]/15 to-[#d6b36a]/15 ring-1 ring-[#8cb478]/20 flex flex-col items-center justify-center text-[#cfe7a7] text-[9px] font-mono gap-0.5">
<span>text</span>
<span className="text-[8px] text-[#cfe7a7]/60">{template.outputFormat}</span>
@@ -305,6 +329,11 @@ function TextTemplateSection({ locked }: { locked: boolean }) {
{template.required && <span className="text-[9px] text-[#e6f578]/80 uppercase tracking-widest"></span>}
</div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
{asset && (
<pre className="mt-2 max-h-36 overflow-y-auto rounded-lg bg-black/32 p-3 text-[11px] leading-relaxed text-white/72 ring-1 ring-white/[0.06] whitespace-pre-wrap">
{asset.content}
</pre>
)}
<button
onClick={() => setShowPromptId(isOpen ? null : template.id)}
className="text-[10px] text-white/30 hover:text-[#cfe7a7] transition-colors flex items-center gap-1"
@@ -322,7 +351,9 @@ function TextTemplateSection({ locked }: { locked: boolean }) {
</div>
<div className="flex flex-col items-end justify-between text-right shrink-0">
<span className="text-[9px] text-white/25 uppercase tracking-wider">{template.outputFormat}</span>
<span className="text-[10px] text-white/25"></span>
<span className={`text-[10px] ${asset ? 'text-[#dff5a8]' : 'text-white/25'}`}>
{asset ? '完成' : '待生成'}
</span>
</div>
</div>
);
@@ -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<string | null>(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 (
<section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-video" aria-disabled={locked}>
@@ -351,8 +387,10 @@ function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
<span className="text-[10px] text-white/35"> · / </span>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 rounded-full bg-white/[0.06]" />
<span className="text-[10px] font-mono text-white/35 shrink-0">{VIDEO_TEMPLATES.length} </span>
<div className="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div className="h-full bg-gradient-to-r from-[#e6f578] to-[#8cb478]" style={{ width: `${Math.round((submittedCount / VIDEO_TEMPLATES.length) * 100)}%` }} />
</div>
<span className="text-[10px] font-mono text-white/35 shrink-0">{submittedCount}/{VIDEO_TEMPLATES.length}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
@@ -363,8 +401,10 @@ function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
{VIDEO_TEMPLATES.map(template => {
const isOpen = showPromptId === template.id;
const task = byTemplate.get(template.id);
const loadingThis = videoLoading === template.id;
return (
<div key={template.id} className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div key={template.id} className="grid grid-cols-[72px_minmax(0,1fr)_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#e6f578]/15 to-[#8cb478]/15 ring-1 ring-[#e6f578]/20 flex flex-col items-center justify-center text-[#e6f578] text-[9px] font-mono gap-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5.2v13.6L18.5 12 8 5.2z" />
@@ -377,6 +417,19 @@ function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
<span className="chip chip-neutral text-[10px] py-0">{template.ratio}</span>
</div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
{task && (
<div className="mt-2 rounded-lg bg-black/28 p-2 text-[10px] leading-relaxed text-white/48 ring-1 ring-white/[0.06]">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[#dff5a8]">{task.status}</span>
{task.taskId && <span className="font-mono text-white/34">{task.taskId}</span>}
{task.videoUrl && (
<a href={task.videoUrl} target="_blank" rel="noreferrer" className="text-[#e6f578] hover:text-white">
</a>
)}
</div>
</div>
)}
<button
onClick={() => setShowPromptId(isOpen ? null : template.id)}
className="text-[10px] text-white/30 hover:text-[#e6f578] transition-colors flex items-center gap-1"
@@ -393,12 +446,12 @@ function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
)}
</div>
<button
onClick={() => onGenerateVideo(primaryImage, template)}
disabled={videoLoading || locked}
onClick={() => task?.taskId ? onRefreshVideo(task.taskId) : onGenerateVideo(primaryImage, template)}
disabled={Boolean(videoLoading) || locked || task?.status === 'succeeded'}
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
>
{locked ? '锁定' : videoLoading ? '...' : '提交'}
{locked ? '锁定' : loadingThis ? '...' : task ? task.status === 'succeeded' ? '完成' : '刷新' : '提交'}
</button>
</div>
);
@@ -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({
</div>
)}
{activeNav === 'pack-text' ? (
<TextTemplateSection locked={!imagePacksComplete} />
<TextTemplateSection
locked={!imagePacksComplete}
session={session}
loading={textLoading}
onGenerateText={onGenerateText}
/>
) : (
<VideoSection
videoLoading={videoLoading}
primaryImage={primaryImage}
locked={!imagePacksComplete}
session={session}
onGenerateVideo={onGenerateVideo}
onRefreshVideo={onRefreshVideo}
/>
)}
</div>

View File

@@ -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',

94
src/lib/textGenerator.ts Normal file
View File

@@ -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<TextGenerationJson>({
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,
})),
};
}

View File

@@ -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<VideoGenerationRequest['ratio']>;
duration: number;
submittedAt: number;
updatedAt: number;
raw?: unknown;
};
export type GenerateTextRequest = {
sessionId: string;
templateIds?: string[];
};
export type GenerateTextResponse = {
textAssets: TextAsset[];
provider: 'mock' | 'gpt';
};

View File

@@ -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<VideoGenerat
status?: string;
video_url?: string;
output?: { video_url?: string; url?: string };
content?: { video_url?: string; url?: string };
};
return {
@@ -114,7 +116,7 @@ export async function getSeedanceVideoTask(taskId: string): Promise<VideoGenerat
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,
videoUrl: raw.video_url || raw.output?.video_url || raw.output?.url || raw.content?.video_url || raw.content?.url,
raw,
};
}