auto-save 2026-05-17 10:56 (+1, ~2)

This commit is contained in:
2026-05-17 10:56:31 +08:00
parent 9d1268bc42
commit a30a9de26e
3 changed files with 1231 additions and 705 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { import {
ReactFlow, Background, BackgroundVariant, Controls, MiniMap, ReactFlow, Background, BackgroundVariant, Controls,
useNodesState, useEdgesState, useNodesState, useEdgesState,
type Node, type Edge, type Node, type Edge,
} from "@xyflow/react" } from "@xyflow/react"
import { Toaster, toast } from "sonner" import { Toaster, toast } from "sonner"
import { LayoutGrid } from "lucide-react"
import { import {
InputNode, VisualLabNode, AudioNode, InputNode, VisualLabNode, AudioNode,
ComposeNode, KeyframePanelNode, ComposeNode, KeyframePanelNode,
@@ -17,6 +16,7 @@ import {
} from "@/components/nodes" } from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle" import { ThemeToggle } from "@/components/theme-toggle"
import { AudioStrip } from "@/components/audio-strip" import { AudioStrip } from "@/components/audio-strip"
import { AdRecreationBoard } from "@/components/ad-recreation-board"
import { import {
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe,
@@ -535,7 +535,7 @@ export default function Home() {
}) })
updateJobInList(updated) updateJobInList(updated)
void navigator.clipboard?.writeText(prompt).catch(() => {}) void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("视频任务已进入 Video Gen 节点") toast.success("视频任务已进入候选片段")
} catch (e) { } catch (e) {
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e))) toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
} }
@@ -666,7 +666,7 @@ export default function Home() {
if (jobs.length === 0) return if (jobs.length === 0) return
// 状态切到 downloaded 时提示用户点解析(仅一次) // 状态切到 downloaded 时提示用户点解析(仅一次)
if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") { if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") {
toast.info("📥 视频已就绪 — 请点 Input 节点里的「点这里开始解析」按钮", { duration: 6000 }) toast.info("视频已就绪,请在左侧看板开始抽帧", { duration: 6000 })
} }
prevStatusRef.current = job?.status ?? null prevStatusRef.current = job?.status ?? null
@@ -767,7 +767,7 @@ export default function Home() {
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const savedSizes = useMemo(() => loadNodeSizes(), []) const savedSizes = useMemo(() => loadNodeSizes(), [])
const [nodes, setNodes, onNodesChange] = useNodesState<Node>( const [nodes, setNodes] = useNodesState<Node>(
LAYOUT.map((n) => { LAYOUT.map((n) => {
const s = savedSizes[n.id] ?? {} const s = savedSizes[n.id] ?? {}
const w = s.w ?? n.w const w = s.w ?? n.w
@@ -868,7 +868,7 @@ export default function Home() {
} }
try { window.localStorage.setItem(NODE_SIZES_KEY, JSON.stringify(sizes)) } catch {} try { window.localStorage.setItem(NODE_SIZES_KEY, JSON.stringify(sizes)) } catch {}
}, [nodes]) }, [nodes])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>( const [, setEdges] = useEdgesState<Edge>(
EDGES_RAW.map(([from, to], i) => ({ EDGES_RAW.map(([from, to], i) => ({
id: `e${i}`, source: from, target: to, animated: false, type: "default", id: `e${i}`, source: from, target: to, animated: false, type: "default",
})), })),
@@ -1002,45 +1002,32 @@ export default function Home() {
return ( return (
<> <>
<div className="canvas-bg" /> <div className="canvas-bg" />
<main className="relative h-screen w-screen overflow-hidden flex"> <main className="relative flex h-screen w-screen overflow-hidden">
{/* 自动整理布局 — 主题切换上方,一键恢复默认位置和宽度 */} <AdRecreationBoard data={nodeData} onGenerateVideo={handleQuickGenerateVideo} />
<div className="absolute z-30 pointer-events-auto" style={{ bottom: 228, left: 12 }}>
<button
type="button"
onClick={handleResetLayout}
className="glass-node h-9 w-9 inline-flex items-center justify-center rounded-xl"
style={{ width: 36, height: 36, padding: 0, borderRadius: 12 }}
title="自动排版 · 保留每个节点的尺寸,重新排好间距和列布局"
>
<LayoutGrid className="h-4 w-4" />
</button>
</div>
{/* 主题切换 — 左下角 Controls 上方(错开) */}
<div className="absolute z-30 pointer-events-auto" style={{ bottom: 180, left: 12 }}>
<ThemeToggle />
</div>
{/* 右区:DAG 节点流图(原顶部 storyboard dock 已删除) */} {/* 右区:暂时清空,只保留无限画布能力,后续再定义要承载的内容。 */}
<section className="relative flex-1 min-h-0 flex flex-col"> <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"> <div className="relative flex-1 min-h-0">
{clientReady ? ( {clientReady ? (
<ReactFlow <ReactFlow
nodes={nodes} nodes={[]}
edges={edges} edges={[]}
onInit={(instance) => { flowRef.current = instance }} onInit={(instance) => { flowRef.current = instance }}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
colorMode={resolvedTheme === "light" ? "light" : "dark"} colorMode={resolvedTheme === "light" ? "light" : "dark"}
fitView fitView
fitViewOptions={{ padding: 0.12 }} fitViewOptions={{ padding: 0.12 }}
minZoom={0.2} minZoom={0.2}
maxZoom={1.5} maxZoom={1.5}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} /> <Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
<Controls position="bottom-left" /> <Controls position="bottom-left" />
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
</ReactFlow> </ReactFlow>
) : ( ) : (
<div className="h-full w-full" suppressHydrationWarning /> <div className="h-full w-full" suppressHydrationWarning />

View File

@@ -0,0 +1,588 @@
"use client"
import { type ReactNode, useEffect, useRef, useState } from "react"
import {
AlertTriangle, Check, Circle, Film, Image as ImageIcon, Loader2,
Mic, Play, Plus, Scissors, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
import {
type FrameExtractQuality,
type FrameExtractTarget,
type GeneratedVideo,
type Job,
type KeyFrame,
type StoryboardScene,
apiAssetUrl,
effectiveFrameUrl,
updateStoryboard,
videoUrl,
} from "@/lib/api"
import { type NodeData } from "@/components/nodes"
const TARGETS: Array<{ value: FrameExtractTarget; label: string }> = [
{ value: "balanced", label: "综合" },
{ value: "subject", label: "主体" },
{ value: "motion", label: "动作" },
{ value: "expression", label: "表情" },
{ value: "transition", label: "转场" },
{ value: "transparent_human", label: "骨架人" },
]
const QUALITIES: Array<{ value: FrameExtractQuality; label: string }> = [
{ value: "auto", label: "自动" },
{ value: "fast", label: "快速" },
{ value: "accurate", label: "精细" },
{ value: "ultra", label: "极准" },
]
const VIDEO_MODELS = [
{ value: "seedance", label: "Seedance" },
{ value: "kling", label: "Kling" },
{ value: "veo3", label: "Veo" },
] 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"
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"
const emptyScene = (): StoryboardScene => ({
duration: 5,
subject: "",
product: "",
scene: "",
action: "",
reference_ids: [],
})
function statusTone(job: Job | null) {
if (!job) return { label: "等待素材", className: "border-white/10 text-white/50 bg-white/[0.03]" }
if (job.status === "failed") return { label: "失败", className: "border-rose-400/30 text-rose-200 bg-rose-500/10" }
if (["created", "downloading", "splitting", "transcribing"].includes(job.status)) {
return { label: "处理中", className: "border-cyan-300/30 text-cyan-100 bg-cyan-400/10" }
}
return { label: "可编辑", className: "border-emerald-300/30 text-emerald-100 bg-emerald-400/10" }
}
function shortId(id?: string | null) {
return id ? id.slice(0, 8) : "-"
}
function formatSeconds(raw?: number) {
if (!raw || Number.isNaN(raw)) return "0.0s"
return `${raw.toFixed(1)}s`
}
function frameLabel(frame: KeyFrame, order: number) {
return `S${String(order + 1).padStart(2, "0")} · ${frame.timestamp.toFixed(1)}s`
}
function videoPoster(job: Job, video: GeneratedVideo) {
return apiAssetUrl(video.poster_url) || (job.frames[0] ? effectiveFrameUrl(job.id, job.frames[0]) : "")
}
function videoSrc(video: GeneratedVideo) {
return apiAssetUrl(video.url)
}
export function AdRecreationBoard({
data,
onGenerateVideo,
}: {
data: NodeData
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
const { job, jobs, activeJobId } = data
const [url, setUrl] = useState("")
const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set())
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 generatedVideos = job?.generated_videos ?? []
const audioReady = !!job?.audio_script?.rewritten_text?.trim()
const submitUrl = () => {
const trimmed = url.trim()
if (!trimmed) return
data.onSubmitUrl(trimmed)
setUrl("")
}
const selectAllFrames = () => {
if (!job) return
for (const frame of job.frames) {
if (!data.selectedFrames.has(frame.index)) data.onToggleFrame(frame.index)
}
}
const clearFrameSelection = () => {
if (!job) return
for (const frame of job.frames) {
if (data.selectedFrames.has(frame.index)) data.onToggleFrame(frame.index)
}
}
const toggleVideo = (videoId: string) => {
setSelectedVideoIds((prev) => {
const next = new Set(prev)
if (next.has(videoId)) next.delete(videoId)
else next.add(videoId)
return next
})
}
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>
</div>
<div className="mt-3 grid grid-cols-4 gap-2 text-[11px] text-white/48">
<Metric label="任务" value={shortId(activeJobId)} />
<Metric label="抽帧" value={`${job?.frames.length ?? 0}`} />
<Metric label="选用" value={`${selectedFrames.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"
>
</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 (
<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"}`}
>
<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>
<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>
<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>
</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>
</aside>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-white/10 bg-black/35 px-2 py-1.5">
<div>{label}</div>
<div className="mt-0.5 truncate font-mono text-[12px] text-white/78">{value}</div>
</div>
)
}
function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
return (
<h2 className="flex items-center gap-2 text-[13px] font-semibold text-white">
<span className="text-rose-200">{icon}</span>
{title}
</h2>
)
}
function WorkflowCard({
icon,
title,
ready,
running,
children,
}: {
icon: ReactNode
title: string
ready: boolean
running?: boolean
children: ReactNode
}) {
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>
)
}
function ActionButton({
children,
disabled,
onClick,
variant = "solid",
}: {
children: ReactNode
disabled?: boolean
onClick?: () => void
variant?: "solid" | "ghost"
}) {
return (
<button
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"}`}
>
{children}
</button>
)
}
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">
{text}
</div>
)
}
function SceneRow({
job,
frame,
order,
onJobUpdate,
onGenerateVideo,
}: {
job: Job
frame: KeyFrame
order: number
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 ?? {}) })
}, [frame.index, frame.storyboard])
const patch = (next: Partial<StoryboardScene>) => setScene((prev) => ({ ...prev, ...next }))
const save = async () => {
setSaving(true)
try {
const updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate(updated)
toast.success(`分镜 ${order + 1} 已保存`)
} catch (e) {
toast.error("保存失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
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" />
<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>
</div>
<div className="mt-1 line-clamp-2 text-[11px] leading-snug text-white/42">{frame.description?.scene || "未识别画面内容"}</div>
</div>
</div>
<div className="grid gap-2">
<textarea
value={scene.scene ?? ""}
onChange={(e) => patch({ scene: e.target.value })}
placeholder="剧情规划:这段广告画面要发生什么"
className={`${fieldClass} min-h-[56px]`}
/>
<textarea
value={scene.product ?? ""}
onChange={(e) => patch({ product: e.target.value })}
placeholder="产品融入SKG 产品如何进入画面、如何被使用"
className={`${fieldClass} min-h-[56px]`}
/>
<textarea
value={scene.action ?? ""}
onChange={(e) => patch({ action: e.target.value })}
placeholder="镜头动作:首帧到尾帧的动作和镜头运动"
className={`${fieldClass} min-h-[56px]`}
/>
<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>
</div>
</div>
</div>
)
}
function VideoCandidate({
job,
video,
selected,
onToggle,
onDelete,
}: {
job: Job
video: GeneratedVideo
selected: boolean
onToggle: () => void
onDelete?: () => void
}) {
const src = videoSrc(video)
const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress"
return (
<div className={`rounded-lg border p-2 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="flex gap-2">
<button type="button" onClick={onToggle} className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
{src && video.status === "completed" ? (
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" />
) : poster ? (
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
)}
<div className="absolute right-1 top-1 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" />}</div>
</button>
<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="删除片段">
<Trash2 className="mx-auto h-3.5 w-3.5" />
</button>
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-white/45">
{running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-200" /> : video.status === "failed" ? <AlertTriangle className="h-3 w-3 text-rose-200" /> : <Film className="h-3 w-3" />}
<span>{video.status}</span>
<span>{formatSeconds(video.duration)}</span>
<span>{video.progress}%</span>
</div>
{video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
{src && video.status === "completed" && (
<a href={src} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
<Play className="h-3 w-3" />
</a>
)}
</div>
</div>
</div>
)
}