feat: persist text and video outputs
This commit is contained in:
60
src/app/api/text/generate/route.ts
Normal file
60
src/app/api/text/generate/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { recordEvent } from '@/lib/auditDb';
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { generateSeedanceVideo } from '@/lib/videoProviders';
|
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 runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -9,13 +11,45 @@ export const dynamic = 'force-dynamic';
|
|||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const body = (await req.json()) as VideoGenerationRequest;
|
const body = (await req.json()) as VideoGenerationRequest;
|
||||||
try {
|
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);
|
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 } });
|
let task: VideoTask | undefined;
|
||||||
return NextResponse.json(response);
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
const message = String(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 }, {
|
return NextResponse.json({ error: message }, {
|
||||||
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
|
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { recordEvent } from '@/lib/auditDb';
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { getSeedanceVideoTask } from '@/lib/videoProviders';
|
import { getSeedanceVideoTask } from '@/lib/videoProviders';
|
||||||
|
import { loadSession, saveSession } from '@/lib/storage';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
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 { taskId } = await ctx.params;
|
||||||
|
const sessionId = new URL(req.url).searchParams.get('sessionId')?.trim();
|
||||||
try {
|
try {
|
||||||
const response = await getSeedanceVideoTask(taskId);
|
const response = await getSeedanceVideoTask(taskId);
|
||||||
recordEvent({ action: 'video.status_checked', targetType: 'video', targetId: taskId, status: 'ok', provider: 'seedance', metadata: { status: response.status } });
|
let task = undefined;
|
||||||
return NextResponse.json(response);
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
const message = String(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 }, {
|
return NextResponse.json({ error: message }, {
|
||||||
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
|
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
GenSession,
|
GenSession,
|
||||||
GeneratePackResponse,
|
GeneratePackResponse,
|
||||||
GenerateResponse,
|
GenerateResponse,
|
||||||
|
GenerateTextResponse,
|
||||||
LockCharacterResponse,
|
LockCharacterResponse,
|
||||||
PackKind,
|
PackKind,
|
||||||
ProjectFromUploadResponse,
|
ProjectFromUploadResponse,
|
||||||
@@ -333,7 +334,8 @@ export default function Home() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingKind, setLoadingKind] = useState<PackKind | null>(null);
|
const [loadingKind, setLoadingKind] = useState<PackKind | null>(null);
|
||||||
const [characterLoading, setCharacterLoading] = useState(false);
|
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 [uploadLoading, setUploadLoading] = useState(false);
|
||||||
const [provider, setProvider] = useState<string>('?');
|
const [provider, setProvider] = useState<string>('?');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
@@ -476,6 +478,20 @@ export default function Home() {
|
|||||||
}, 5000);
|
}, 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) {
|
async function handleLockCharacter(image: GenImage) {
|
||||||
if (!current || characterLoading) return;
|
if (!current || characterLoading) return;
|
||||||
setCharacterLoading(true);
|
setCharacterLoading(true);
|
||||||
@@ -508,9 +524,30 @@ export default function Home() {
|
|||||||
return { url: image.url, label: '意向图' };
|
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]) {
|
async function handleGenerateVideo(image: GenImage, template: typeof VIDEO_TEMPLATES[number]) {
|
||||||
if (!current || videoLoading) return;
|
if (!current || videoLoading) return;
|
||||||
setVideoLoading(true);
|
setVideoLoading(template.id);
|
||||||
try {
|
try {
|
||||||
const character = current.characterSpec
|
const character = current.characterSpec
|
||||||
? `${current.characterSpec.name},${current.characterSpec.oneLiner}`
|
? `${current.characterSpec.name},${current.characterSpec.oneLiner}`
|
||||||
@@ -521,6 +558,9 @@ export default function Home() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
sessionId: current.id,
|
||||||
|
templateId: template.id,
|
||||||
|
templateTitle: template.title,
|
||||||
prompt,
|
prompt,
|
||||||
imageUrl: anchor.url,
|
imageUrl: anchor.url,
|
||||||
duration: template.duration,
|
duration: template.duration,
|
||||||
@@ -534,9 +574,25 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const d: VideoGenerationResponse = await r.json();
|
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 {
|
} 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}
|
session={current}
|
||||||
activeNav={activeAssetPanel}
|
activeNav={activeAssetPanel}
|
||||||
onActiveNavChange={setActiveAssetPanel}
|
onActiveNavChange={setActiveAssetPanel}
|
||||||
|
textLoading={textLoading}
|
||||||
videoLoading={videoLoading}
|
videoLoading={videoLoading}
|
||||||
|
onGenerateText={handleGenerateText}
|
||||||
onGenerateVideo={handleGenerateVideo}
|
onGenerateVideo={handleGenerateVideo}
|
||||||
|
onRefreshVideo={handleRefreshVideo}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<ProjectGalleryDrawer
|
<ProjectGalleryDrawer
|
||||||
|
|||||||
@@ -265,8 +265,21 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Text Template Section ────────────────────── */
|
/* ── 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 [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 (
|
return (
|
||||||
<section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-text" aria-disabled={locked}>
|
<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>
|
<span className="text-[10px] text-white/35">专利说明 · 工厂说明 · 宣发文案 · 视频脚本</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<div className="flex-1 h-1 rounded-full bg-white/[0.06]" />
|
<div className="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
|
||||||
<span className="text-[10px] font-mono text-white/35 shrink-0">0/{TEXT_TEMPLATES.length}</span>
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
|
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
|
||||||
{TEXT_TEMPLATES.map(template => {
|
{TEXT_TEMPLATES.map(template => {
|
||||||
const isOpen = showPromptId === template.id;
|
const isOpen = showPromptId === template.id;
|
||||||
|
const asset = byTemplate.get(template.id);
|
||||||
return (
|
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">
|
<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>text</span>
|
||||||
<span className="text-[8px] text-[#cfe7a7]/60">{template.outputFormat}</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>}
|
{template.required && <span className="text-[9px] text-[#e6f578]/80 uppercase tracking-widest">必备</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
|
<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
|
<button
|
||||||
onClick={() => setShowPromptId(isOpen ? null : template.id)}
|
onClick={() => setShowPromptId(isOpen ? null : template.id)}
|
||||||
className="text-[10px] text-white/30 hover:text-[#cfe7a7] transition-colors flex items-center gap-1"
|
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>
|
||||||
<div className="flex flex-col items-end justify-between text-right shrink-0">
|
<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-[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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -333,13 +364,18 @@ function TextTemplateSection({ locked }: { locked: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Video Section ────────────────────────────── */
|
/* ── Video Section ────────────────────────────── */
|
||||||
function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
|
function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateVideo, onRefreshVideo }: {
|
||||||
videoLoading: boolean;
|
videoLoading: string | null;
|
||||||
primaryImage: GenImage;
|
primaryImage: GenImage;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
session: GenSession;
|
||||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
||||||
|
onRefreshVideo: (taskId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [showPromptId, setShowPromptId] = useState<string | null>(null);
|
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 (
|
return (
|
||||||
<section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-video" aria-disabled={locked}>
|
<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>
|
<span className="text-[10px] text-white/35">异步任务 · 宣发 / 展示短片</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<div className="flex-1 h-1 rounded-full bg-white/[0.06]" />
|
<div className="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
|
||||||
<span className="text-[10px] font-mono text-white/35 shrink-0">{VIDEO_TEMPLATES.length} 个模板</span>
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<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">
|
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
|
||||||
{VIDEO_TEMPLATES.map(template => {
|
{VIDEO_TEMPLATES.map(template => {
|
||||||
const isOpen = showPromptId === template.id;
|
const isOpen = showPromptId === template.id;
|
||||||
|
const task = byTemplate.get(template.id);
|
||||||
|
const loadingThis = videoLoading === template.id;
|
||||||
return (
|
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">
|
<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">
|
<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" />
|
<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>
|
<span className="chip chip-neutral text-[10px] py-0">{template.ratio}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
|
<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
|
<button
|
||||||
onClick={() => setShowPromptId(isOpen ? null : template.id)}
|
onClick={() => setShowPromptId(isOpen ? null : template.id)}
|
||||||
className="text-[10px] text-white/30 hover:text-[#e6f578] transition-colors flex items-center gap-1"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onGenerateVideo(primaryImage, template)}
|
onClick={() => task?.taskId ? onRefreshVideo(task.taskId) : onGenerateVideo(primaryImage, template)}
|
||||||
disabled={videoLoading || locked}
|
disabled={Boolean(videoLoading) || locked || task?.status === 'succeeded'}
|
||||||
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
|
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
|
||||||
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
|
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
|
||||||
>
|
>
|
||||||
{locked ? '锁定' : videoLoading ? '...' : '提交'}
|
{locked ? '锁定' : loadingThis ? '...' : task ? task.status === 'succeeded' ? '完成' : '刷新' : '提交'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -473,14 +526,20 @@ export default function PackPanel({
|
|||||||
session,
|
session,
|
||||||
activeNav,
|
activeNav,
|
||||||
onActiveNavChange,
|
onActiveNavChange,
|
||||||
|
textLoading,
|
||||||
videoLoading,
|
videoLoading,
|
||||||
|
onGenerateText,
|
||||||
onGenerateVideo,
|
onGenerateVideo,
|
||||||
|
onRefreshVideo,
|
||||||
}: {
|
}: {
|
||||||
session: GenSession;
|
session: GenSession;
|
||||||
activeNav: string;
|
activeNav: string;
|
||||||
onActiveNavChange: (id: string) => void;
|
onActiveNavChange: (id: string) => void;
|
||||||
videoLoading: boolean;
|
textLoading: boolean;
|
||||||
|
videoLoading: string | null;
|
||||||
|
onGenerateText: () => void;
|
||||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
||||||
|
onRefreshVideo: (taskId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const selectedImages = session.images.filter(image => image.status === 'selected');
|
const selectedImages = session.images.filter(image => image.status === 'selected');
|
||||||
const primaryImage = selectedImages[0] ?? null;
|
const primaryImage = selectedImages[0] ?? null;
|
||||||
@@ -545,13 +604,20 @@ export default function PackPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeNav === 'pack-text' ? (
|
{activeNav === 'pack-text' ? (
|
||||||
<TextTemplateSection locked={!imagePacksComplete} />
|
<TextTemplateSection
|
||||||
|
locked={!imagePacksComplete}
|
||||||
|
session={session}
|
||||||
|
loading={textLoading}
|
||||||
|
onGenerateText={onGenerateText}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<VideoSection
|
<VideoSection
|
||||||
videoLoading={videoLoading}
|
videoLoading={videoLoading}
|
||||||
primaryImage={primaryImage}
|
primaryImage={primaryImage}
|
||||||
locked={!imagePacksComplete}
|
locked={!imagePacksComplete}
|
||||||
|
session={session}
|
||||||
onGenerateVideo={onGenerateVideo}
|
onGenerateVideo={onGenerateVideo}
|
||||||
|
onRefreshVideo={onRefreshVideo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const VIDEO_TEMPLATES = [
|
|||||||
description: '用于电商和内部评审,展示整体体积、正背侧轮廓。',
|
description: '用于电商和内部评审,展示整体体积、正背侧轮廓。',
|
||||||
duration: 6,
|
duration: 6,
|
||||||
ratio: '16:9',
|
ratio: '16:9',
|
||||||
promptTemplate: '生成 360 度旋转展示视频:{character}. 白底或浅灰棚拍,镜头稳定,玩具缓慢旋转,展示正面、侧面、背面、顶部细节,真实毛绒质感。',
|
promptTemplate: '生成 360 度旋转展示视频:{character}. 白底或浅灰棚拍,镜头稳定,潮流公仔缓慢旋转,展示正面、侧面、背面、顶部细节,强调 ABS/PVC 或搪胶玩具体块、高光黑面罩、耳机和配件结构。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'video_unboxing',
|
id: 'video_unboxing',
|
||||||
@@ -27,15 +27,15 @@ export const VIDEO_TEMPLATES = [
|
|||||||
description: '用于新品宣发,展示包装到玩具出现的过程。',
|
description: '用于新品宣发,展示包装到玩具出现的过程。',
|
||||||
duration: 8,
|
duration: 8,
|
||||||
ratio: '9:16',
|
ratio: '9:16',
|
||||||
promptTemplate: '生成玩具开箱短片:{character}. 竖版社媒风格,从礼盒打开到玩具出现,温暖光线,突出礼物感和治愈感。',
|
promptTemplate: '生成玩具开箱短片:{character}. 竖版社媒风格,从黑金礼盒打开到公仔出现,温暖但克制的棚拍光线,突出收藏感、街头音乐气质、面罩灯效和配件陈列。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'video_touch_detail',
|
id: 'video_touch_detail',
|
||||||
title: '触感细节',
|
title: '触感细节',
|
||||||
description: '展示揉捏、毛绒、刺绣和配件细节。',
|
description: '展示面罩、耳机、服装纹理和配件细节。',
|
||||||
duration: 6,
|
duration: 6,
|
||||||
ratio: '9:16',
|
ratio: '9:16',
|
||||||
promptTemplate: '生成玩具触感细节短片:{character}. 近景镜头,展示毛绒柔软、刺绣五官、配件细节和可抱触感,节奏轻柔。',
|
promptTemplate: '生成玩具细节短片:{character}. 近景镜头,展示高光黑声波面罩、白色头盔边框、针织帽纹理、耳机结构、链条、滑板和喷漆罐配件,节奏清楚,避免毛绒或布偶质感。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'video_story_intro',
|
id: 'video_story_intro',
|
||||||
@@ -43,7 +43,7 @@ export const VIDEO_TEMPLATES = [
|
|||||||
description: '用于 IP 设定和社媒发布。',
|
description: '用于 IP 设定和社媒发布。',
|
||||||
duration: 8,
|
duration: 8,
|
||||||
ratio: '16:9',
|
ratio: '16:9',
|
||||||
promptTemplate: '生成玩具角色故事介绍视频:{character}. 轻剧情镜头,展示角色登场、表情、配件和性格气质,适合新品发布。',
|
promptTemplate: '生成玩具角色故事介绍视频:{character}. 轻剧情镜头,角色在街头音乐和滑板氛围中登场,展示声波面罩情绪变化、耳机、滑板、喷漆罐和黑金配色,适合新品发布。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'video_factory_preview',
|
id: 'video_factory_preview',
|
||||||
|
|||||||
94
src/lib/textGenerator.ts
Normal file
94
src/lib/textGenerator.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export type GenSession = {
|
|||||||
preFilledSlots?: PreFilledSlot[];
|
preFilledSlots?: PreFilledSlot[];
|
||||||
characterSpec?: CharacterSpec;
|
characterSpec?: CharacterSpec;
|
||||||
packs?: AssetPack[];
|
packs?: AssetPack[];
|
||||||
|
textAssets?: TextAsset[];
|
||||||
|
videoTasks?: VideoTask[];
|
||||||
exports?: ExportManifest[];
|
exports?: ExportManifest[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +120,21 @@ export type TextTemplate = {
|
|||||||
checklist: string[];
|
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 = {
|
export type ToyAsset = {
|
||||||
id: string;
|
id: string;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
@@ -265,6 +282,9 @@ export type RegenerateAssetResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type VideoGenerationRequest = {
|
export type VideoGenerationRequest = {
|
||||||
|
sessionId?: string;
|
||||||
|
templateId?: string;
|
||||||
|
templateTitle?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
references?: Array<{
|
references?: Array<{
|
||||||
@@ -287,3 +307,32 @@ export type VideoGenerationResponse = {
|
|||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
raw?: unknown;
|
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';
|
||||||
|
};
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promi
|
|||||||
status?: string;
|
status?: string;
|
||||||
video_url?: string;
|
video_url?: string;
|
||||||
output?: { video_url?: string; url?: string };
|
output?: { video_url?: string; url?: string };
|
||||||
|
content?: { video_url?: string; url?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -86,7 +87,7 @@ export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promi
|
|||||||
model: SEEDANCE_MODEL,
|
model: SEEDANCE_MODEL,
|
||||||
taskId: raw.task_id || raw.id,
|
taskId: raw.task_id || raw.id,
|
||||||
status: normalizeStatus(raw.status),
|
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,
|
raw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,6 +108,7 @@ export async function getSeedanceVideoTask(taskId: string): Promise<VideoGenerat
|
|||||||
status?: string;
|
status?: string;
|
||||||
video_url?: string;
|
video_url?: string;
|
||||||
output?: { video_url?: string; url?: string };
|
output?: { video_url?: string; url?: string };
|
||||||
|
content?: { video_url?: string; url?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -114,7 +116,7 @@ export async function getSeedanceVideoTask(taskId: string): Promise<VideoGenerat
|
|||||||
model: SEEDANCE_MODEL,
|
model: SEEDANCE_MODEL,
|
||||||
taskId: raw.task_id || raw.id || taskId,
|
taskId: raw.task_id || raw.id || taskId,
|
||||||
status: normalizeStatus(raw.status),
|
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,
|
raw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user