auto-save 2026-05-17 11:23 (~3)

This commit is contained in:
2026-05-17 11:23:22 +08:00
parent 2e19f4b108
commit 201abc60d1
3 changed files with 565 additions and 330 deletions

View File

@@ -1007,40 +1007,11 @@ export default function Home() {
<div className="canvas-bg" />
<main className="relative flex h-screen w-screen overflow-hidden">
<AdRecreationBoard data={nodeData} onGenerateVideo={handleQuickGenerateVideo} />
{/* 右区:暂时清空,只保留无限画布能力,后续再定义要承载的内容。 */}
<section className="relative flex min-h-0 flex-1 flex-col">
<div className="absolute z-30 pointer-events-auto" style={{ bottom: 112, left: 12 }}>
<ThemeToggle />
</div>
<div className="relative flex-1 min-h-0">
{clientReady ? (
<ReactFlow
nodes={[]}
edges={[]}
onInit={(instance) => { flowRef.current = instance }}
colorMode={resolvedTheme === "light" ? "light" : "dark"}
fitView
fitViewOptions={{ padding: 0.12 }}
minZoom={0.2}
maxZoom={1.5}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
<Controls position="bottom-left" />
</ReactFlow>
) : (
<div className="h-full w-full" suppressHydrationWarning />
)}
</div>
{clientReady && <AudioStrip job={audioStripJob} open={!!audioStripJob} onClose={() => setAudioStripJobId(null)} />}
</section>
<div className="absolute right-4 top-4 z-30 pointer-events-auto">
<ThemeToggle />
</div>
{clientReady && <AudioStrip job={audioStripJob} open={!!audioStripJob} onClose={() => setAudioStripJobId(null)} />}
<Toaster theme="system" position="top-center" />
</main>
</>
)

View File

@@ -2,8 +2,8 @@
import { type ReactNode, useEffect, useRef, useState } from "react"
import {
AlertTriangle, Check, Circle, Film, Image as ImageIcon, Loader2,
Mic, Play, Plus, Scissors, Trash2, Upload, Wand2,
AlertTriangle, Check, Circle, Film, FileText, Image as ImageIcon, Link2, Loader2,
Mic, Package, PanelRight, Play, Plus, Scissors, Sparkles, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
import {
@@ -13,8 +13,13 @@ import {
type Job,
type KeyFrame,
type StoryboardScene,
addElement,
apiAssetUrl,
cutoutElement,
effectiveFrameUrl,
generatedImageUrl,
hasCutout,
representativeCutoutUrl,
updateStoryboard,
videoUrl,
} from "@/lib/api"
@@ -43,10 +48,10 @@ const VIDEO_MODELS = [
] as const
const controlClass =
"h-9 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40"
"h-10 rounded-md border border-white/10 bg-black/55 px-3 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40"
const fieldClass =
"w-full resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[12px] leading-relaxed text-white outline-none transition placeholder:text-white/28 focus:border-cyan-300/60"
"w-full resize-y rounded-md border border-white/10 bg-black/35 px-3 py-2 text-[12px] leading-relaxed text-white outline-none transition placeholder:text-white/28 focus:border-cyan-300/60"
const emptyScene = (): StoryboardScene => ({
duration: 5,
@@ -87,6 +92,20 @@ function videoSrc(video: GeneratedVideo) {
return apiAssetUrl(video.url)
}
function audioPreview(job: Job | null) {
if (!job) return "导入素材后,先解析音频,再把产品内容改写成新的分镜文字。"
const rewritten = job.audio_script?.rewritten_text?.trim()
if (rewritten) return rewritten
if (job.transcript?.length) return job.transcript.slice(0, 5).map((item) => item.en || item.zh).join(" ")
return "暂无音频文案。解析后这里会作为新剧情和分镜文字的依据。"
}
function storyboardFrames(job: Job | null, selectedFrames: KeyFrame[]) {
if (!job) return []
if (selectedFrames.length > 0) return selectedFrames
return [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
}
export function AdRecreationBoard({
data,
onGenerateVideo,
@@ -97,13 +116,15 @@ export function AdRecreationBoard({
const { job, jobs, activeJobId } = data
const [url, setUrl] = useState("")
const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set())
const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
const fileRef = useRef<HTMLInputElement | null>(null)
const tone = statusTone(job)
const selectedFrames = job
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
: []
const framesForStoryboard = storyboardFrames(job, selectedFrames)
const generatedVideos = job?.generated_videos ?? []
const audioReady = !!job?.audio_script?.rewritten_text?.trim()
const tone = statusTone(job)
const submitUrl = () => {
const trimmed = url.trim()
@@ -135,230 +156,380 @@ export function AdRecreationBoard({
})
}
const generateElementForFrame = async (frame: KeyFrame) => {
if (!job) return
setElementBusyFrame(frame.index)
try {
const existing = frame.elements?.[0]
if (existing) {
const updated = await cutoutElement(job.id, frame.index, existing.id)
data.onJobUpdate(updated)
toast.success(`已生成元素:${existing.name_zh || existing.name_en || "主体"}`)
return
}
const firstObject = frame.description?.objects?.[0]
const name = firstObject?.name?.trim() || "主体"
const added = await addElement(job.id, frame.index, {
name_zh: name,
name_en: name,
position: firstObject?.position,
source: "manual",
})
const latestFrame = added.frames.find((item) => item.index === frame.index)
const newElement = latestFrame?.elements?.[latestFrame.elements.length - 1]
if (!newElement) {
data.onJobUpdate(added)
toast.success(`已登记元素:${name}`)
return
}
const updated = await cutoutElement(job.id, frame.index, newElement.id)
data.onJobUpdate(updated)
toast.success(`已生成元素:${name}`)
} catch (e) {
toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setElementBusyFrame(null)
}
}
return (
<aside className="relative z-20 h-screen w-[520px] min-w-[520px] border-r border-white/10 bg-black/90 text-white shadow-2xl">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_15%_0%,rgba(225,29,72,0.22),transparent_28%),radial-gradient(circle_at_90%_12%,rgba(14,165,233,0.14),transparent_30%)] pointer-events-none" />
<div className="relative h-full overflow-y-auto px-4 py-4">
<header className="mb-4 rounded-lg border border-white/10 bg-white/[0.045] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/40">feed ad remake</div>
<h1 className="mt-1 text-[20px] font-semibold leading-tight text-white">广</h1>
</div>
<span className={`shrink-0 rounded-md border px-2 py-1 text-[12px] font-medium ${tone.className}`}>{tone.label}</span>
<section className="relative z-20 h-screen w-screen overflow-hidden bg-black text-white">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_9%_0%,rgba(225,29,72,0.18),transparent_28%),radial-gradient(circle_at_60%_0%,rgba(14,165,233,0.08),transparent_26%)]" />
<div className="relative flex h-full flex-col px-4 py-4">
<header className="mb-3 flex items-center justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/40">feed ad remake board</div>
<h1 className="mt-1 text-[22px] font-semibold leading-tight text-white">广</h1>
</div>
<div className="mt-3 grid grid-cols-4 gap-2 text-[11px] text-white/48">
<Metric label="任务" value={shortId(activeJobId)} />
<div className="grid min-w-[520px] grid-cols-5 gap-2 text-[11px] text-white/48">
<Metric label="素材" value={`${jobs.length}`} />
<Metric label="当前" value={shortId(activeJobId)} />
<Metric label="抽帧" value={`${job?.frames.length ?? 0}`} />
<Metric label="选用" value={`${selectedFrames.length}`} />
<Metric label="分镜" value={`${framesForStoryboard.length}`} />
<Metric label="片段" value={`${generatedVideos.length}`} />
</div>
</header>
<section className="space-y-3 rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center justify-between">
<SectionTitle icon={<Plus className="h-4 w-4" />} title="素材输入" />
{jobs.length > 1 && (
<select
value={activeJobId ?? ""}
onChange={(e) => e.target.value && data.onSwitchJob(e.target.value)}
className="h-8 max-w-[180px] rounded-md border border-white/10 bg-black/50 px-2 text-[12px] text-white outline-none focus:border-cyan-300/60"
>
{jobs.map((item) => (
<option key={item.id} value={item.id}>{shortId(item.id)} · {item.status}</option>
))}
</select>
)}
</div>
<div className="flex gap-2">
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submitUrl() }}
placeholder="粘贴 TK / 信息流视频链接"
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/45 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/60"
/>
<button
type="button"
onClick={submitUrl}
disabled={data.submitting || !url.trim()}
className="h-10 rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45"
<div className="min-h-0 flex-1 overflow-x-auto pb-2">
<div className="grid h-full min-w-[1520px] grid-cols-[320px_390px_1fr_360px] gap-3">
<BoardColumn
icon={<Plus className="h-4 w-4" />}
step="01"
title="素材输入"
subtitle="一个素材就是一次文件任务"
>
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="h-10 w-10 rounded-md border border-white/10 bg-white/[0.06] text-white/75 transition hover:border-white/25 hover:bg-white/[0.1]"
aria-label="上传视频"
title="上传视频"
>
<Upload className="mx-auto h-4 w-4" />
</button>
<input
ref={fileRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) data.onUploadFile(file)
e.currentTarget.value = ""
}}
/>
</div>
{job?.video_url && (
<video
src={videoUrl(job.id)}
controls
playsInline
className="aspect-[9/16] max-h-[280px] w-full rounded-lg border border-white/10 bg-black object-contain"
/>
)}
</section>
<section className="mt-3 grid grid-cols-1 gap-3">
<WorkflowCard
icon={<Scissors className="h-4 w-4" />}
title="视频抽帧"
ready={!!job?.frames.length}
running={data.analyzing || job?.status === "splitting"}
>
<div className="grid grid-cols-[1fr_1fr_72px] gap-2">
<select
value={job ? data.frameTargets[job.id] ?? "transparent_human" : "balanced"}
onChange={(e) => job && data.onFrameTargetChange(job.id, e.target.value as FrameExtractTarget)}
disabled={!job}
className={controlClass}
>
{TARGETS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<select
value={job ? data.frameQualities[job.id] ?? "auto" : "auto"}
onChange={(e) => job && data.onFrameQualityChange(job.id, e.target.value as FrameExtractQuality)}
disabled={!job}
className={controlClass}
>
{QUALITIES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<input
type="number"
min={1}
max={20}
value={job ? data.frameCounts[job.id] ?? 12 : 12}
onChange={(e) => job && data.onFrameCountChange(job.id, Number(e.target.value) || 12)}
disabled={!job}
className={`${controlClass} text-center`}
/>
</div>
<div className="mt-2 flex gap-2">
<ActionButton disabled={!job || data.analyzing} onClick={() => data.onAnalyze({ mode: job?.frames.length ? "append" : "replace" })}>
{data.analyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
{job?.frames.length ? "追加抽帧" : "开始抽帧"}
</ActionButton>
<ActionButton disabled={!job?.frames.length} variant="ghost" onClick={selectAllFrames}></ActionButton>
<ActionButton disabled={!selectedFrames.length} variant="ghost" onClick={clearFrameSelection}></ActionButton>
</div>
</WorkflowCard>
<WorkflowCard
icon={<Mic className="h-4 w-4" />}
title="音频文案"
ready={audioReady}
running={job?.status === "transcribing" || job?.audio_script?.status === "rewriting"}
>
<div className="flex gap-2">
<ActionButton disabled={!job?.video_url || job.status === "transcribing"} onClick={() => data.onTranscribeAudio?.(job?.id)}>
<Mic className="h-3.5 w-3.5" />
</ActionButton>
<ActionButton disabled={!job?.source_audio_url && !job?.audio_script?.voice_url} variant="ghost" onClick={() => data.onOpenAudioStrip?.(job?.id)}></ActionButton>
</div>
<div className="mt-2 max-h-32 overflow-y-auto rounded-md border border-white/10 bg-black/35 p-2 text-[12px] leading-relaxed text-white/62">
{audioReady ? job?.audio_script?.rewritten_text : job?.transcript?.length ? job.transcript.slice(0, 4).map((s) => s.en || s.zh).join(" ") : "暂无文案。导入视频后先抽帧,再解析音频生成口播文案。"}
</div>
</WorkflowCard>
</section>
<section className="mt-3 rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center justify-between gap-2">
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧选择" />
<span className="text-[11px] text-white/40">{selectedFrames.length}/{job?.frames.length ?? 0}</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{job?.frames.length ? job.frames.map((frame, index) => {
const active = data.selectedFrames.has(frame.index)
return (
<div className="flex gap-2">
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submitUrl() }}
placeholder="粘贴 TK / 信息流视频链接"
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/45 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/60"
/>
<button
key={frame.index}
type="button"
onClick={() => data.onToggleFrame(frame.index)}
className={`group relative overflow-hidden rounded-lg border bg-black text-left transition ${active ? "border-rose-400/80 shadow-[0_0_0_1px_rgba(244,63,94,0.45)]" : "border-white/10 hover:border-white/25"}`}
onClick={submitUrl}
disabled={data.submitting || !url.trim()}
className="inline-flex h-10 items-center justify-center rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45"
>
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, index)} className="aspect-[9/16] w-full object-cover opacity-90 transition group-hover:opacity-100" />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-1.5 pt-5">
<div className="flex items-center justify-between text-[10px] font-medium text-white">
<span>{frameLabel(frame, index)}</span>
{active ? <Check className="h-3.5 w-3.5 text-rose-200" /> : <Circle className="h-3 w-3 text-white/50" />}
</div>
</div>
</button>
)
}) : (
<EmptyState text="抽帧后这里会出现可选分镜。" />
)}
</div>
</section>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-white/10 bg-white/[0.06] text-white/75 transition hover:border-white/25 hover:bg-white/[0.1]"
aria-label="上传视频"
title="上传视频"
>
<Upload className="h-4 w-4" />
</button>
<input
ref={fileRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) data.onUploadFile(file)
e.currentTarget.value = ""
}}
/>
</div>
<section className="mt-3 rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center justify-between">
<SectionTitle icon={<Wand2 className="h-4 w-4" />} title="剧情规划 / 产品融入" />
<span className="text-[11px] text-white/40">{selectedFrames.length} </span>
</div>
<div className="mt-3 space-y-3">
{job && selectedFrames.length > 0 ? selectedFrames.map((frame, order) => (
<SceneRow
key={`${job.id}:${frame.index}`}
job={job}
frame={frame}
order={order}
onJobUpdate={data.onJobUpdate}
onGenerateVideo={onGenerateVideo}
/>
)) : (
<EmptyState text="先在关键帧里选择要复刻的分镜。" />
)}
</div>
</section>
<div className="space-y-2 overflow-y-auto pr-1">
{jobs.length ? jobs.map((item, index) => (
<MaterialCard
key={item.id}
job={item}
index={index}
active={item.id === activeJobId}
onClick={() => data.onSwitchJob(item.id)}
onDelete={data.onDeleteJob ? () => data.onDeleteJob?.(item.id) : undefined}
/>
)) : (
<EmptyState text="还没有素材。每导入一个链接或上传一个文件,就会新增一个素材任务。" />
)}
</div>
<section className="mt-3 rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center justify-between">
<SectionTitle icon={<Film className="h-4 w-4" />} title="候选片段" />
<span className="text-[11px] text-white/40"> {selectedVideoIds.size}</span>
{job?.video_url && (
<video
src={videoUrl(job.id)}
controls
playsInline
className="aspect-video w-full rounded-lg border border-white/10 bg-black object-contain"
/>
)}
</BoardColumn>
<BoardColumn
icon={<FileText className="h-4 w-4" />}
step="02"
title="音频解析 / 新分镜文案"
subtitle="按产品内容改写,分镜自上而下排列"
>
<div className="rounded-lg border border-white/10 bg-black/32 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<Mic className="h-4 w-4" />} title="音频文案" />
<StatusPill ready={audioReady} running={job?.status === "transcribing" || job?.audio_script?.status === "rewriting"} />
</div>
<div className="flex gap-2">
<ActionButton disabled={!job?.video_url || job.status === "transcribing"} onClick={() => data.onTranscribeAudio?.(job?.id)}>
<Mic className="h-3.5 w-3.5" />
</ActionButton>
<ActionButton disabled={!job?.source_audio_url && !job?.audio_script?.voice_url} variant="ghost" onClick={() => data.onOpenAudioStrip?.(job?.id)}>
</ActionButton>
</div>
<div className="mt-2 max-h-28 overflow-y-auto rounded-md border border-white/10 bg-black/35 p-2 text-[12px] leading-relaxed text-white/62">
{audioPreview(job)}
</div>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{job && framesForStoryboard.length > 0 ? framesForStoryboard.map((frame, order) => (
<SceneRow
key={`${job.id}:${frame.index}`}
job={job}
frame={frame}
order={order}
selected={data.selectedFrames.has(frame.index)}
onToggle={() => data.onToggleFrame(frame.index)}
onJobUpdate={data.onJobUpdate}
/>
)) : (
<EmptyState text="抽帧后,这里会按时间从上到下列出新分镜文字。" />
)}
</div>
</BoardColumn>
<BoardColumn
icon={<Scissors className="h-4 w-4" />}
step="03"
title="视频关键元素 / 抽帧生成"
subtitle="关键帧横向展开,直接生成元素和片段"
>
<div className="rounded-lg border border-white/10 bg-black/32 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<Scissors className="h-4 w-4" />} title="关键帧抽取" />
<StatusPill ready={!!job?.frames.length} running={data.analyzing || job?.status === "splitting"} />
</div>
<div className="grid grid-cols-[1fr_1fr_72px] gap-2">
<select
value={job ? data.frameTargets[job.id] ?? "transparent_human" : "balanced"}
onChange={(e) => job && data.onFrameTargetChange(job.id, e.target.value as FrameExtractTarget)}
disabled={!job}
className={controlClass}
>
{TARGETS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<select
value={job ? data.frameQualities[job.id] ?? "auto" : "auto"}
onChange={(e) => job && data.onFrameQualityChange(job.id, e.target.value as FrameExtractQuality)}
disabled={!job}
className={controlClass}
>
{QUALITIES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<input
type="number"
min={1}
max={20}
value={job ? data.frameCounts[job.id] ?? 12 : 12}
onChange={(e) => job && data.onFrameCountChange(job.id, Number(e.target.value) || 12)}
disabled={!job}
className={`${controlClass} text-center`}
/>
</div>
<div className="mt-2 flex flex-wrap gap-2">
<ActionButton disabled={!job || data.analyzing} onClick={() => data.onAnalyze({ mode: job?.frames.length ? "append" : "replace" })}>
{data.analyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
{job?.frames.length ? "追加抽帧" : "开始抽帧"}
</ActionButton>
<ActionButton disabled={!job?.frames.length} variant="ghost" onClick={selectAllFrames}></ActionButton>
<ActionButton disabled={!selectedFrames.length} variant="ghost" onClick={clearFrameSelection}></ActionButton>
</div>
</div>
<div className="min-h-0 flex-1 overflow-x-auto">
<div className="flex h-full min-w-max gap-3 pr-2">
{job?.frames.length ? job.frames.map((frame, index) => (
<ElementFrameCard
key={frame.index}
job={job}
frame={frame}
order={index}
selected={data.selectedFrames.has(frame.index)}
busy={elementBusyFrame === frame.index}
onToggle={() => data.onToggleFrame(frame.index)}
onGenerateElement={() => generateElementForFrame(frame)}
onGenerateVideo={onGenerateVideo}
/>
)) : (
<div className="w-[420px]">
<EmptyState text="抽帧后,关键帧会横向排列;每张帧卡可生成元素、选入分镜、生成候选片段。" />
</div>
)}
</div>
</div>
</BoardColumn>
<BoardColumn
icon={<Film className="h-4 w-4" />}
step="04"
title="视频合成"
subtitle="音频和候选视频合成完整广告"
>
<div className="rounded-lg border border-white/10 bg-black/32 p-3">
<SectionTitle icon={<PanelRight className="h-4 w-4" />} title="合成输入" />
<div className="mt-3 grid gap-2 text-[12px] text-white/58">
<Requirement label="音频文案" ready={audioReady} detail={audioReady ? "已生成" : "待解析"} />
<Requirement label="候选片段" ready={selectedVideoIds.size > 0} detail={`已选 ${selectedVideoIds.size}`} />
<Requirement label="合成接口" ready={false} detail="待接入" />
</div>
<button
type="button"
disabled
className="mt-3 inline-flex h-10 w-full cursor-not-allowed items-center justify-center gap-2 rounded-md border border-white/10 bg-white/[0.04] text-[13px] font-semibold text-white/34"
>
<Film className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{job && generatedVideos.length > 0 ? generatedVideos.map((video) => (
<VideoCandidate
key={video.id}
job={job}
video={video}
selected={selectedVideoIds.has(video.id)}
onToggle={() => toggleVideo(video.id)}
onDelete={() => data.onDeleteVideo?.(video.id)}
/>
)) : (
<EmptyState text="生成候选片段后,在这里勾选要合成的版本。" />
)}
</div>
</BoardColumn>
</div>
<div className="mt-3 space-y-2">
{job && generatedVideos.length > 0 ? generatedVideos.map((video) => (
<VideoCandidate
key={video.id}
job={job}
video={video}
selected={selectedVideoIds.has(video.id)}
onToggle={() => toggleVideo(video.id)}
onDelete={() => data.onDeleteVideo?.(video.id)}
/>
)) : (
<EmptyState text="生成片段后,在这里选择可组合的候选。" />
)}
</div>
</section>
</div>
</div>
</aside>
</section>
)
}
function Metric({ label, value }: { label: string; value: string }) {
function BoardColumn({
icon,
step,
title,
subtitle,
children,
}: {
icon: ReactNode
step: string
title: string
subtitle: string
children: ReactNode
}) {
return (
<div className="rounded-md border border-white/10 bg-black/35 px-2 py-1.5">
<section className="flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
<header className="shrink-0 border-b border-white/10 pb-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-rose-500/12 text-rose-100">{icon}</span>
<span className="font-mono text-[12px] text-white/36">{step}</span>
</div>
</div>
<h2 className="text-[15px] font-semibold leading-tight text-white">{title}</h2>
<p className="mt-1 text-[12px] leading-snug text-white/42">{subtitle}</p>
</header>
{children}
</section>
)
}
function MaterialCard({
job,
index,
active,
onClick,
onDelete,
}: {
job: Job
index: number
active: boolean
onClick: () => void
onDelete?: () => void
}) {
const tone = statusTone(job)
return (
<button
type="button"
onClick={onClick}
className={`group w-full rounded-lg border p-3 text-left transition ${active ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/28 hover:border-white/24 hover:bg-white/[0.045]"}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-[12px] text-white/78"> {String(index + 1).padStart(2, "0")}</div>
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-white/42">
<Link2 className="h-3 w-3" />
<span className="truncate">{job.url || shortId(job.id)}</span>
</div>
</div>
<span className={`shrink-0 rounded-md border px-2 py-1 text-[11px] ${tone.className}`}>{tone.label}</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px] text-white/44">
<Metric label="帧" value={`${job.frames.length}`} compact />
<Metric label="音频" value={job.audio_script?.rewritten_text ? "ready" : "-"} compact />
<Metric label="片段" value={`${job.generated_videos?.length ?? 0}`} compact />
</div>
{onDelete && (
<span
role="button"
tabIndex={0}
onClick={(event) => { event.stopPropagation(); onDelete() }}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
event.stopPropagation()
onDelete()
}
}}
className="mt-3 hidden h-8 items-center justify-center gap-1 rounded-md border border-white/10 text-[11px] text-white/50 transition hover:border-rose-300/40 hover:text-rose-200 group-hover:flex"
>
<Trash2 className="h-3.5 w-3.5" />
</span>
)}
</button>
)
}
function Metric({ label, value, compact }: { label: string; value: string; compact?: boolean }) {
return (
<div className={`rounded-md border border-white/10 bg-black/35 ${compact ? "px-2 py-1" : "px-2 py-1.5"}`}>
<div>{label}</div>
<div className="mt-0.5 truncate font-mono text-[12px] text-white/78">{value}</div>
</div>
@@ -367,37 +538,19 @@ function Metric({ label, value }: { label: string; value: string }) {
function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
return (
<h2 className="flex items-center gap-2 text-[13px] font-semibold text-white">
<h3 className="flex items-center gap-2 text-[13px] font-semibold text-white">
<span className="text-rose-200">{icon}</span>
{title}
</h2>
</h3>
)
}
function WorkflowCard({
icon,
title,
ready,
running,
children,
}: {
icon: ReactNode
title: string
ready: boolean
running?: boolean
children: ReactNode
}) {
function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) {
return (
<section className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="mb-2 flex items-center justify-between">
<SectionTitle icon={icon} title={title} />
<span className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] ${running ? "border-cyan-300/25 text-cyan-100 bg-cyan-400/10" : ready ? "border-emerald-300/25 text-emerald-100 bg-emerald-400/10" : "border-white/10 text-white/42 bg-white/[0.03]"}`}>
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : ready ? <Check className="h-3 w-3" /> : <Circle className="h-2.5 w-2.5" />}
{running ? "运行中" : ready ? "已就绪" : "待处理"}
</span>
</div>
{children}
</section>
<span className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] ${running ? "border-cyan-300/25 text-cyan-100 bg-cyan-400/10" : ready ? "border-emerald-300/25 text-emerald-100 bg-emerald-400/10" : "border-white/10 text-white/42 bg-white/[0.03]"}`}>
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : ready ? <Check className="h-3 w-3" /> : <Circle className="h-2.5 w-2.5" />}
{running ? "运行中" : ready ? "已就绪" : "待处理"}
</span>
)
}
@@ -417,7 +570,7 @@ function ActionButton({
type="button"
disabled={disabled}
onClick={onClick}
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-md px-3 text-[12px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${variant === "solid" ? "bg-white text-black hover:bg-white/90" : "border border-white/10 bg-white/[0.04] text-white/72 hover:border-white/25 hover:text-white"}`}
className={`inline-flex h-10 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 text-[12px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${variant === "solid" ? "bg-white text-black hover:bg-white/90" : "border border-white/10 bg-white/[0.04] text-white/72 hover:border-white/25 hover:text-white"}`}
>
{children}
</button>
@@ -426,7 +579,7 @@ function ActionButton({
function EmptyState({ text }: { text: string }) {
return (
<div className="col-span-full rounded-lg border border-dashed border-white/12 bg-black/25 px-3 py-6 text-center text-[12px] text-white/38">
<div className="rounded-lg border border-dashed border-white/12 bg-black/25 px-3 py-8 text-center text-[12px] text-white/38">
{text}
</div>
)
@@ -436,19 +589,19 @@ function SceneRow({
job,
frame,
order,
selected,
onToggle,
onJobUpdate,
onGenerateVideo,
}: {
job: Job
frame: KeyFrame
order: number
selected: boolean
onToggle: () => void
onJobUpdate: (job: Job) => void
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
const [scene, setScene] = useState<StoryboardScene>(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) }))
const [model, setModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance")
const [saving, setSaving] = useState(false)
const [generating, setGenerating] = useState(false)
useEffect(() => {
setScene({ ...emptyScene(), ...(frame.storyboard ?? {}) })
@@ -469,65 +622,176 @@ function SceneRow({
}
}
const generate = async () => {
setGenerating(true)
try {
await save()
await onGenerateVideo(frame.index, scene, model)
} finally {
setGenerating(false)
}
}
return (
<div className="rounded-lg border border-white/10 bg-black/32 p-2.5">
<div className="mb-2 flex gap-2">
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="h-20 w-12 rounded-md border border-white/10 object-cover" />
<article className={`rounded-lg border p-2.5 transition ${selected ? "border-rose-400/60 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="mb-2 flex items-start gap-2">
<button
type="button"
onClick={onToggle}
className="relative h-16 w-10 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black"
aria-label={selected ? "取消选中分镜" : "选中分镜"}
>
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="h-full w-full object-cover" />
<span className="absolute right-0.5 top-0.5 rounded-full bg-black/70 p-0.5">
{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}
</span>
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="font-mono text-[12px] text-white/78">{frameLabel(frame, order)}</div>
<select value={model} onChange={(e) => setModel(e.target.value as (typeof VIDEO_MODELS)[number]["value"])} className="h-7 rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white outline-none">
{VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<label className="flex h-8 items-center gap-1 rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white/45">
<input
type="number"
min={1}
step={0.5}
value={scene.duration || 5}
onChange={(e) => patch({ duration: Number(e.target.value) || 5 })}
className="w-12 bg-transparent text-center font-mono text-white outline-none"
/>
</label>
</div>
<div className="mt-1 line-clamp-2 text-[11px] leading-snug text-white/42">{frame.description?.scene || "未识别画面内容"}</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-white/42">{frame.description?.scene || "等待生成新的分镜文字"}</p>
</div>
</div>
<div className="grid gap-2">
<textarea
value={scene.scene ?? ""}
onChange={(e) => patch({ scene: e.target.value })}
placeholder="剧情规划:这段广告画面要发生什么"
className={`${fieldClass} min-h-[56px]`}
placeholder="剧情:根据原音频文案和产品内容,这一镜要讲什么"
className={`${fieldClass} min-h-[52px]`}
/>
<textarea
value={scene.product ?? ""}
onChange={(e) => patch({ product: e.target.value })}
placeholder="产品融入SKG 产品如何进入画面、如何被使用"
className={`${fieldClass} min-h-[56px]`}
placeholder="产品融入SKG 产品在哪里出现,怎么被使用"
className={`${fieldClass} min-h-[52px]`}
/>
<textarea
value={scene.action ?? ""}
onChange={(e) => patch({ action: e.target.value })}
placeholder="镜头动作:首帧到尾帧的动作和镜头运动"
className={`${fieldClass} min-h-[56px]`}
placeholder="动作 / 镜头:首尾变化、手部动作、运镜节奏"
className={`${fieldClass} min-h-[52px]`}
/>
<div className="flex items-center gap-2">
<label className="flex h-9 items-center gap-2 rounded-md border border-white/10 bg-black/35 px-2 text-[12px] text-white/50">
<input
type="number"
min={1}
step={0.5}
value={scene.duration || 5}
onChange={(e) => patch({ duration: Number(e.target.value) || 5 })}
className="w-14 bg-transparent text-center font-mono text-white outline-none"
/>
</label>
<ActionButton variant="ghost" disabled={saving} onClick={save}>{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}</ActionButton>
<ActionButton disabled={generating} onClick={generate}>{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}</ActionButton>
<ActionButton variant="ghost" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</ActionButton>
</div>
</article>
)
}
function ElementFrameCard({
job,
frame,
order,
selected,
busy,
onToggle,
onGenerateElement,
onGenerateVideo,
}: {
job: Job
frame: KeyFrame
order: number
selected: boolean
busy: boolean
onToggle: () => void
onGenerateElement: () => void
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
const [model, setModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance")
const [generatingVideo, setGeneratingVideo] = useState(false)
const elements = frame.elements ?? []
const generatedImages = frame.generated_images ?? []
const objectNames = frame.description?.objects?.slice(0, 4).map((item) => item.name).filter(Boolean) ?? []
const elementPreviews = elements
.map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) }))
.filter((item): item is { element: typeof elements[number]; src: string } => !!item.src)
const generateVideo = async () => {
setGeneratingVideo(true)
try {
await onGenerateVideo(frame.index, { ...emptyScene(), ...(frame.storyboard ?? {}) }, model)
} finally {
setGeneratingVideo(false)
}
}
return (
<article className={`flex h-full w-[280px] shrink-0 flex-col rounded-lg border p-2.5 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<button
type="button"
onClick={onToggle}
className="group relative aspect-[9/16] w-full overflow-hidden rounded-md border border-white/10 bg-black"
aria-label={selected ? "取消选中关键帧" : "选中关键帧"}
>
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="h-full w-full object-cover opacity-92 transition group-hover:opacity-100" />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-2 pt-8">
<div className="flex items-center justify-between text-[11px] font-medium text-white">
<span>{frameLabel(frame, order)}</span>
{selected ? <Check className="h-4 w-4 text-rose-200" /> : <Circle className="h-3.5 w-3.5 text-white/55" />}
</div>
</div>
</button>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[11px] text-white/44">
<Package className="h-3.5 w-3.5" />
<span>{elements.filter(hasCutout).length}/{elements.length || objectNames.length || 0} </span>
</div>
<div className="flex items-center gap-1 text-[11px] text-white/44">
<ImageIcon className="h-3.5 w-3.5" />
<span>{generatedImages.length} </span>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{(elements.length ? elements.map((item) => item.name_zh || item.name_en) : objectNames).slice(0, 5).map((name) => (
<span key={name} className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/50">{name}</span>
))}
{!elements.length && !objectNames.length && <span className="text-[11px] text-white/32"></span>}
</div>
{(elementPreviews.length > 0 || generatedImages.length > 0) && (
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">
{elementPreviews.slice(0, 4).map(({ element, src }) => (
<img key={element.id} src={src} alt={element.name_zh || element.name_en} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
))}
{generatedImages.slice(0, 4).map((image) => (
<img key={image.id} src={generatedImageUrl(job.id, frame.index, image.id)} alt={image.prompt || "生成图"} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
))}
</div>
)}
<div className="mt-auto grid gap-2 pt-3">
<ActionButton disabled={busy} variant="ghost" onClick={onGenerateElement}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
</ActionButton>
<div className="grid grid-cols-[1fr_auto] gap-2">
<select value={model} onChange={(e) => setModel(e.target.value as (typeof VIDEO_MODELS)[number]["value"])} className="h-10 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none">
{VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
<ActionButton disabled={generatingVideo} onClick={generateVideo}>
{generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</ActionButton>
</div>
</div>
</article>
)
}
function Requirement({ label, ready, detail }: { label: string; ready: boolean; detail: string }) {
return (
<div className="flex items-center justify-between rounded-md border border-white/10 bg-black/28 px-3 py-2">
<div className="flex items-center gap-2">
{ready ? <Check className="h-3.5 w-3.5 text-emerald-200" /> : <Circle className="h-3.5 w-3.5 text-white/38" />}
<span>{label}</span>
</div>
<span className="font-mono text-[11px] text-white/42">{detail}</span>
</div>
)
}
@@ -564,7 +828,7 @@ function VideoCandidate({
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="truncate font-mono text-[12px] text-white/80">{shortId(video.id)} · {video.model}</div>
<button type="button" onClick={onDelete} className="h-7 w-7 rounded-md border border-white/10 text-white/45 transition hover:border-rose-300/40 hover:text-rose-200" aria-label="删除片段">
<button type="button" onClick={onDelete} className="h-8 w-8 rounded-md border border-white/10 text-white/45 transition hover:border-rose-300/40 hover:text-rose-200" aria-label="删除片段">
<Trash2 className="mx-auto h-3.5 w-3.5" />
</button>
</div>