Files
20260512-skg-tk/web/components/ad-recreation-board.tsx

1275 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react"
import {
AlertTriangle, Check, ChevronDown, 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 {
type FrameExtractQuality,
type FrameExtractTarget,
type FrameObject,
type GeneratedVideo,
type Job,
type KeyElement,
type KeyFrame,
type StoryboardScene,
type SubjectKind,
addElement,
apiAssetUrl,
cutoutElement,
effectiveFrameUrl,
generateSubjectAssets,
generatedImageUrl,
hasCutout,
representativeCutoutUrl,
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
type VideoModel = (typeof VIDEO_MODELS)[number]["value"]
type DraftSegment = {
id: string
frameIndex: number | null
scene: StoryboardScene
}
const controlClass =
"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-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,
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)
}
function audioPreview(job: Job | null) {
if (!job) return "粘贴 TK 链接或上传视频后,系统会先下载视频;下载完成后自动提取音频文案。"
const source = job.audio_script?.source_text?.trim() || job.audio_script?.source_zh?.trim()
if (source) return source
if (job.transcript?.length) return job.transcript.slice(0, 5).map((item) => item.en || item.zh).join(" ")
return "暂无音频文案。下载完成后会自动提取原音频文案、讲话人和背景音。"
}
function orderedFrames(job: Job | null, selectedFrames: KeyFrame[]) {
if (!job) return []
if (selectedFrames.length > 0) return selectedFrames
return [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
}
function countReadySegments(job: Job | null, drafts: DraftSegment[]) {
const frameStoryboards = job?.frames.filter((frame) => !!frame.storyboard).length ?? 0
const draftCount = drafts.length
return frameStoryboards + draftCount
}
function guessSubjectKind(name: string): SubjectKind {
return /人|人物|模特|骨架|身体|脸|手|person|people|human|body|face|hand|character/i.test(name)
? "living"
: "object"
}
function buildFallbackScene(job: Job, frame: KeyFrame, order: number): StoryboardScene {
const frames = [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null
const duration = Math.max(3.5, Math.min(7.5, Math.max(job.duration || 0, frames.length * 5) / Math.max(frames.length, 1)))
const audio = job.audio_script?.rewritten_text?.trim()
|| job.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ")
|| "按原音频说话节奏改写为 SKG 产品介绍。"
const objects = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、")
return {
duration: Number(duration.toFixed(1)),
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` },
last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null,
subject: objects ? `关键元素候选:${objects}` : "保留原视频最重要的主体动作和构图关系。",
scene: `${frame.description?.scene || `参考第 ${order + 1} 个关键画面规划 SKG 信息流广告分镜。`}\n音频节奏依据${audio.slice(0, 220)}`,
product: "把原素材里的产品/痛点转成 SKG 颈部/肩颈按摩仪表达,默认使用 SKG 四张产品角度图做产品真源。",
action: frame.description?.style
? `沿用原画面的讲话节奏、动作节点和 ${frame.description.style},突出使用前紧绷、使用后放松。`
: "沿用原视频的讲话节奏和动作节点,突出使用前紧绷、使用后放松。",
reference_ids: [],
}
}
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 [draftSegments, setDraftSegments] = useState<DraftSegment[]>([])
const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
const [sixViewBusyKey, setSixViewBusyKey] = useState<string | null>(null)
const [generatingAll, setGeneratingAll] = useState(false)
const fileRef = useRef<HTMLInputElement | null>(null)
const selectedFrames = job
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
: []
const framesForSegments = orderedFrames(job, selectedFrames)
const generatedVideos = job?.generated_videos ?? []
const audioReady = !!job?.audio_script?.source_text?.trim() || !!job?.transcript?.length
const readySegments = countReadySegments(job, draftSegments)
const transcriptCount = job?.transcript.length ?? 0
const backgroundReady = !!job?.audio_script?.background_audio_profile?.trim()
useEffect(() => {
setDraftSegments([])
setSelectedVideoIds(new Set())
}, [activeJobId])
const submitUrl = () => {
const trimmed = url.trim()
if (!trimmed) return
data.onSubmitUrl(trimmed)
setUrl("")
}
const startProduction = () => {
const trimmed = url.trim()
data.onStartProduction?.(trimmed || undefined)
if (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 addDraftSegment = () => {
setDraftSegments((prev) => [
...prev,
{
id: `draft-${Date.now()}-${prev.length}`,
frameIndex: null,
scene: emptyScene(),
},
])
}
const updateDraftSegment = (id: string, patch: Partial<DraftSegment>) => {
setDraftSegments((prev) => prev.map((draft) => draft.id === id ? { ...draft, ...patch } : draft))
}
const removeDraftSegment = (id: string) => {
setDraftSegments((prev) => prev.filter((draft) => draft.id !== id))
}
const toggleVideo = (videoId: string) => {
setSelectedVideoIds((prev) => {
const next = new Set(prev)
if (next.has(videoId)) next.delete(videoId)
else next.add(videoId)
return next
})
}
const generateElementForFrame = async (frame: KeyFrame, candidate?: FrameObject, withSixViews = true) => {
if (!job) return
setElementBusyFrame(frame.index)
const candidateName = candidate?.name?.trim()
try {
let workingJob = job
let workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? frame
const existing = workingFrame.elements?.find((item) =>
candidateName
? [item.name_zh, item.name_en].some((name) => name?.trim() === candidateName)
: true,
)
const sourceObject = candidate ?? workingFrame.description?.objects?.[0]
const name = candidateName || sourceObject?.name?.trim() || existing?.name_zh || existing?.name_en || "主体"
let element = existing
if (!element) {
workingJob = await addElement(job.id, frame.index, {
name_zh: name,
name_en: name,
position: sourceObject?.position,
source: "manual",
})
data.onJobUpdate(workingJob)
workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
element = workingFrame.elements?.[workingFrame.elements.length - 1]
}
if (!element) {
toast.success(`已登记元素:${name}`)
return
}
if (!hasCutout(element)) {
workingJob = await cutoutElement(job.id, frame.index, element.id)
data.onJobUpdate(workingJob)
workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
element = workingFrame.elements?.find((item) => item.id === element?.id) ?? element
}
if (withSixViews && !element.subject_assets?.length) {
setSixViewBusyKey(`${frame.index}:${element.id}`)
workingJob = await generateSubjectAssets(job.id, frame.index, element.id, {
subject_kind: guessSubjectKind(name),
background: "white",
size: "1024",
source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
})
data.onJobUpdate(workingJob)
}
toast.success(`已准备关键元素:${name}`)
} catch (e) {
toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setElementBusyFrame(null)
setSixViewBusyKey(null)
}
}
const generateSixViewsForElement = async (frame: KeyFrame, element: KeyElement) => {
if (!job) return
setSixViewBusyKey(`${frame.index}:${element.id}`)
try {
const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
subject_kind: guessSubjectKind(element.name_zh || element.name_en || "主体"),
background: "white",
size: "1024",
source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
})
data.onJobUpdate(updated)
toast.success(`6 视图已生成:${element.name_zh || element.name_en}`)
} catch (e) {
toast.error("6 视图生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSixViewBusyKey(null)
}
}
const generateAllVideos = async () => {
if (!job || framesForSegments.length === 0) return
setGeneratingAll(true)
try {
for (let order = 0; order < framesForSegments.length; order += 1) {
const frame = framesForSegments[order]
const scene = frame.storyboard ?? buildFallbackScene(job, frame, order)
if (!frame.storyboard) {
const updated = await updateStoryboard(job.id, frame.index, scene)
data.onJobUpdate(updated)
}
await onGenerateVideo(frame.index, scene, "seedance")
}
toast.success(`已提交 ${framesForSegments.length} 条分镜视频`)
} catch (e) {
toast.error("批量生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setGeneratingAll(false)
}
}
return (
<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 audio intake board</div>
<h1 className="mt-1 text-[22px] font-semibold leading-tight text-white">广</h1>
</div>
<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?.video_url ? "ready" : "-"} />
<Metric label="文案段" value={`${transcriptCount}`} />
<Metric label="背景音" value={backgroundReady ? "ready" : "-"} />
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)] gap-3">
<MaterialColumn
data={data}
jobs={jobs}
job={job}
activeJobId={activeJobId}
url={url}
setUrl={setUrl}
fileRef={fileRef}
onSubmitUrl={submitUrl}
onStartProduction={startProduction}
/>
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl">
<header className="shrink-0 border-b border-white/10 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<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"><Mic className="h-4 w-4" /></span>
<span className="font-mono text-[12px] text-white/36">02</span>
</div>
<h2 className="mt-2 text-[17px] font-semibold leading-tight text-white"></h2>
<p className="mt-1 text-[12px] text-white/42"></p>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<ActionButton disabled={!job?.video_url || job.status === "transcribing"} onClick={() => data.onTranscribeAudio?.(job?.id)}>
<Mic className="h-3.5 w-3.5" />
</ActionButton>
</div>
</div>
<div className="mt-3 grid grid-cols-1 items-start gap-3 xl:grid-cols-[minmax(0,1fr)_470px]">
<details className="group rounded-lg border border-white/10 bg-black/32 p-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
<SectionTitle icon={<FileText className="h-4 w-4" />} title="音频文案依据" />
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] text-white/38">{transcriptCount ? `${transcriptCount}` : "待解析"}</span>
<StatusPill ready={audioReady} running={job?.status === "transcribing" || job?.audio_script?.status === "rewriting"} />
<ChevronDown className="h-4 w-4 text-white/38 transition group-open:rotate-180" />
</div>
</summary>
<div className="mt-3 max-h-24 overflow-y-auto rounded-md border border-white/10 bg-black/35 p-2 text-[12px] leading-relaxed text-white/62">
{audioPreview(job)}
</div>
</details>
<AudioIntakeStatus job={job} audioReady={audioReady} />
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<AudioIntakePanel job={job} />
</div>
<footer className="shrink-0 border-t border-white/10 p-3">
<AudioStepSummary job={job} audioReady={audioReady} />
</footer>
</section>
</div>
</div>
</section>
)
}
function MaterialColumn({
data,
jobs,
job,
activeJobId,
url,
setUrl,
fileRef,
onSubmitUrl,
onStartProduction,
}: {
data: NodeData
jobs: Job[]
job: Job | null
activeJobId: string | null
url: string
setUrl: (value: string) => void
fileRef: RefObject<HTMLInputElement | null>
onSubmitUrl: () => void
onStartProduction: () => void
}) {
return (
<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 gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-rose-500/12 text-rose-100"><Plus className="h-4 w-4" /></span>
<span className="font-mono text-[12px] text-white/36">01</span>
</div>
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
<p className="mt-1 text-[12px] leading-snug text-white/42"></p>
</header>
<div className="flex gap-2">
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") onSubmitUrl() }}
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={onStartProduction}
disabled={data.submitting || (!url.trim() && !job)}
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"
>
</button>
<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>
<div className="min-h-0 flex-1 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>
{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"
/>
)}
</section>
)
}
function AudioIntakeStatus({ job, audioReady }: { job: Job | null; audioReady: boolean }) {
const downloading = !!job && ["created", "downloading"].includes(job.status)
const audioRunning = !!job && (job.status === "transcribing" || job.audio_script?.status === "rewriting")
return (
<div className="rounded-lg border border-white/10 bg-black/32 p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<PanelRight className="h-4 w-4" />} title="当前步骤" />
<StatusPill ready={audioReady} running={downloading || audioRunning} />
</div>
<div className="grid grid-cols-4 gap-2 text-[11px] text-white/52">
<Requirement label="素材" ready={!!job} detail={job ? shortId(job.id) : "待输入"} />
<Requirement label="视频" ready={!!job?.video_url} detail={downloading ? "下载中" : job?.video_url ? "已就绪" : "待下载"} />
<Requirement label="音频" ready={!!job?.source_audio_url} detail={audioRunning ? "解析中" : job?.source_audio_url ? "已提取" : "待提取"} />
<Requirement label="文案" ready={audioReady} detail={audioReady ? `${job?.transcript.length ?? 0}` : "待解析"} />
</div>
<div className="mt-2 truncate rounded-md border border-white/10 bg-black/28 px-3 py-2 text-[11px] text-white/42" title={job?.message}>
{job?.message || "粘贴 TK 链接或上传视频后,点击开始进入下载和音频解析。"}
</div>
</div>
)
}
function AudioIntakePanel({ job }: { job: Job | null }) {
if (!job) {
return <EmptyState text="先在左侧粘贴 TK 链接或上传本地视频。点击开始后,会先下载视频,再自动解析原音频文案、讲话人节奏和背景音。" />
}
const script = job.audio_script
const profiles = [
{ label: "讲话人", value: script?.speaker_profile },
{ label: "节奏", value: script?.rhythm_profile },
{ label: "背景音", value: script?.background_audio_profile },
]
const processing = job.status === "transcribing" || script?.status === "rewriting"
return (
<section className="rounded-lg border border-white/10 bg-black/28 p-2.5">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<Film className="h-4 w-4" />} title="音频解析结果" />
<div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
<span>{job.transcript.length} </span>
<span>{formatSeconds(job.duration)}</span>
</div>
</div>
<div className="mb-2 grid grid-cols-3 gap-2">
{profiles.map((item) => (
<ProfileTile key={item.label} label={item.label} value={item.value} running={processing} />
))}
</div>
<div className="mb-2 flex items-center justify-between gap-3 border-t border-white/8 pt-2">
<SectionTitle icon={<FileText className="h-4 w-4" />} title="逐句时间轴" />
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">{job.transcript.length} </span>
</div>
{job.transcript.length ? (
<div className="overflow-hidden rounded-md border border-white/10">
<div className="grid grid-cols-[82px_minmax(0,1fr)_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-white/50">
<div></div>
<div></div>
<div></div>
</div>
<div className="max-h-[164px] overflow-y-auto">
{job.transcript.map((segment) => (
<div key={segment.index} className="grid grid-cols-[82px_minmax(0,1fr)_minmax(0,1fr)] gap-3 border-b border-white/8 px-3 py-1.5 text-[11.5px] leading-snug text-white/64 last:border-b-0">
<div className="font-mono text-[11px] text-white/38">{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
<div className="truncate" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
<div className="truncate" title={segment.zh}>{segment.zh || <span className="text-white/30"></span>}</div>
</div>
))}
</div>
</div>
) : (
<EmptyState text={processing ? "音频解析中,完成后这里会按时间列出原文案和中文翻译。" : "下载完成后会自动解析音频;也可以点击右上角“解析音频”手动重试。"} />
)}
</section>
)
}
function ProfileTile({ label, value, running }: { label: string; value?: string; running?: boolean }) {
return (
<div className="min-h-[74px] rounded-md border border-white/10 bg-black/35 p-2.5">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-white/48">{label}</span>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin text-cyan-200" /> : value ? <Check className="h-3.5 w-3.5 text-emerald-200" /> : <Circle className="h-3.5 w-3.5 text-white/32" />}
</div>
<p className="max-h-[34px] overflow-hidden text-[11.5px] leading-snug text-white/62" title={value}>
{value || (running ? "模型分析中..." : "等待音频分析结果。")}
</p>
</div>
)
}
function FrameExtractControls({
job,
data,
selectedFramesCount,
onSelectAllFrames,
onClearFrameSelection,
}: {
job: Job | null
data: NodeData
selectedFramesCount: number
onSelectAllFrames: () => void
onClearFrameSelection: () => void
}) {
return (
<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={onSelectAllFrames}></ActionButton>
<ActionButton disabled={!selectedFramesCount} variant="ghost" onClick={onClearFrameSelection}></ActionButton>
</div>
</div>
)
}
function StoryboardSegmentCard({
job,
frame,
order,
selected,
selectedVideoIds,
videos,
busy,
sixViewBusyKey,
onToggleFrame,
onJobUpdate,
onGenerateElement,
onGenerateSixViews,
onGenerateVideo,
onToggleVideo,
onDeleteVideo,
}: {
job: Job
frame: KeyFrame
order: number
selected: boolean
selectedVideoIds: Set<string>
videos: GeneratedVideo[]
busy: boolean
sixViewBusyKey: string | null
onToggleFrame: () => void
onJobUpdate: (job: Job) => void
onGenerateElement: (candidate?: FrameObject) => void
onGenerateSixViews: (element: KeyElement) => void
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
onToggleVideo: (videoId: string) => void
onDeleteVideo?: (videoId: string) => void
}) {
const [scene, setScene] = useState<StoryboardScene>(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) }))
const [model, setModel] = useState<VideoModel>("seedance")
const [saving, setSaving] = useState(false)
const [generatingVideo, setGeneratingVideo] = useState(false)
const elements = frame.elements ?? []
const generatedImages = frame.generated_images ?? []
const objectCandidates = frame.description?.objects?.slice(0, 8).filter((item) => item.name?.trim()) ?? []
const objectNames = objectCandidates.map((item) => item.name)
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)
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 generateVideo = async () => {
setGeneratingVideo(true)
try {
await save()
await onGenerateVideo(frame.index, scene, model)
} finally {
setGeneratingVideo(false)
}
}
return (
<article className={`rounded-lg border p-3 transition ${selected ? "border-rose-400/60 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<button
type="button"
onClick={onToggleFrame}
className="relative h-24 w-14 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-1 top-1 rounded-full bg-black/70 p-0.5">
{selected ? <Check className="h-3.5 w-3.5 text-rose-200" /> : <Circle className="h-3.5 w-3.5 text-white/55" />}
</span>
</button>
<div>
<div className="font-mono text-[12px] text-white/48"> {String(order + 1).padStart(2, "0")}</div>
<h3 className="mt-1 text-[15px] font-semibold text-white">{frameLabel(frame, order)}</h3>
<p className="mt-1 max-w-[520px] line-clamp-2 text-[12px] leading-relaxed text-white/42">{frame.description?.scene || "等待生成新的分镜文字"}</p>
</div>
</div>
<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>
</div>
<div className="grid gap-3">
<SegmentBand icon={<FileText className="h-4 w-4" />} title="音频分镜文案">
<div className="grid gap-2 lg:grid-cols-3">
<textarea
value={scene.scene ?? ""}
onChange={(e) => patch({ scene: e.target.value })}
placeholder="新剧情:根据原音频和产品内容,这一镜要讲什么"
className={`${fieldClass} min-h-[72px]`}
/>
<textarea
value={scene.product ?? ""}
onChange={(e) => patch({ product: e.target.value })}
placeholder="产品融入SKG 产品在哪里出现,怎么被使用"
className={`${fieldClass} min-h-[72px]`}
/>
<textarea
value={scene.action ?? ""}
onChange={(e) => patch({ action: e.target.value })}
placeholder="动作 / 镜头:首尾变化、手部动作、运镜节奏"
className={`${fieldClass} min-h-[72px]`}
/>
</div>
<div className="mt-2 flex justify-end">
<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>
</SegmentBand>
<SegmentBand icon={<Package className="h-4 w-4" />} title="每个分镜需要的关键元素">
<div className="grid gap-3 lg:grid-cols-[170px_minmax(0,1fr)_auto]">
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="aspect-video w-full rounded-md border border-white/10 bg-black object-cover" />
<div className="min-w-0">
<div className="mb-2 flex items-center gap-2 text-[11px] text-white/44">
<ImageIcon className="h-3.5 w-3.5" />
<span>{elements.filter(hasCutout).length}/{elements.length || objectNames.length || 0} </span>
<span>{generatedImages.length} </span>
</div>
<div className="mb-2 flex flex-wrap gap-1">
{objectCandidates.length > 0 && objectCandidates.map((candidate) => (
<button
key={`${candidate.name}:${candidate.position ?? ""}`}
type="button"
onClick={() => onGenerateElement(candidate)}
disabled={busy}
className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
title="选择该元素并生成提取图 + 6 视图"
>
{candidate.name}
</button>
))}
{!objectCandidates.length && !elements.length && <span className="text-[11px] text-white/32"></span>}
</div>
{elements.length > 0 && (
<div className="mb-2 grid gap-1">
{elements.slice(0, 5).map((element) => {
const busySix = sixViewBusyKey === `${frame.index}:${element.id}`
return (
<div key={element.id} className="flex items-center justify-between gap-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5">
<span className="min-w-0 truncate text-[11px] text-white/62">{element.name_zh || element.name_en}</span>
<button
type="button"
onClick={() => onGenerateSixViews(element)}
disabled={busySix}
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/10 px-2 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
>
{busySix ? <Loader2 className="h-3 w-3 animate-spin" /> : <ImageIcon className="h-3 w-3" />}
{element.subject_assets?.length ? `${element.subject_assets.length}视图` : "6视图"}
</button>
</div>
)
})}
</div>
)}
{(elementPreviews.length > 0 || generatedImages.length > 0) && (
<div className="flex gap-1 overflow-x-auto pb-1">
{elementPreviews.slice(0, 6).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, 6).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>
<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" />}
+6
</ActionButton>
</div>
</SegmentBand>
<SegmentBand icon={<Film className="h-4 w-4" />} title="视频生成">
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="grid gap-2">
<select value={model} onChange={(e) => setModel(e.target.value as VideoModel)} 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 className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{videos.length > 0 ? videos.map((video) => (
<VideoCandidate
key={video.id}
job={job}
video={video}
selected={selectedVideoIds.has(video.id)}
onToggle={() => onToggleVideo(video.id)}
onDelete={() => onDeleteVideo?.(video.id)}
/>
)) : (
<EmptyState text="这里会出现本分镜生成出的候选视频。" />
)}
</div>
</div>
</SegmentBand>
</div>
</article>
)
}
function DraftSegmentCard({
draft,
order,
job,
onPatch,
onRemove,
onJobUpdate,
onGenerateVideo,
}: {
draft: DraftSegment
order: number
job: Job | null
onPatch: (patch: Partial<DraftSegment>) => void
onRemove: () => void
onJobUpdate: (job: Job) => void
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
}) {
const [model, setModel] = useState<VideoModel>("seedance")
const [saving, setSaving] = useState(false)
const [generatingVideo, setGeneratingVideo] = useState(false)
const boundFrame = draft.frameIndex !== null ? job?.frames.find((frame) => frame.index === draft.frameIndex) ?? null : null
const patchScene = (next: Partial<StoryboardScene>) => {
onPatch({ scene: { ...draft.scene, ...next } })
}
const save = async () => {
if (!job || draft.frameIndex === null) {
toast.info("先给草稿分镜绑定一个关键帧,再保存。")
return
}
setSaving(true)
try {
const updated = await updateStoryboard(job.id, draft.frameIndex, draft.scene)
onJobUpdate(updated)
toast.success(`草稿分镜 ${order + 1} 已保存到关键帧`)
} catch (e) {
toast.error("保存失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
const generateVideo = async () => {
if (!job || draft.frameIndex === null) {
toast.info("先绑定关键帧,视频生成需要首帧参考。")
return
}
setGeneratingVideo(true)
try {
await save()
await onGenerateVideo(draft.frameIndex, draft.scene, model)
} finally {
setGeneratingVideo(false)
}
}
return (
<article className="rounded-lg border border-dashed border-cyan-300/22 bg-cyan-500/[0.045] p-3">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="font-mono text-[12px] text-cyan-100/50">稿 {String(order + 1).padStart(2, "0")}</div>
<h3 className="mt-1 text-[15px] font-semibold text-white"></h3>
<p className="mt-1 text-[12px] text-white/42"></p>
</div>
<button type="button" onClick={onRemove} className="h-9 w-9 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-4 w-4" />
</button>
</div>
<div className="grid gap-3">
<SegmentBand icon={<FileText className="h-4 w-4" />} title="音频分镜文案">
<div className="grid gap-2 lg:grid-cols-3">
<textarea value={draft.scene.scene ?? ""} onChange={(e) => patchScene({ scene: e.target.value })} placeholder="新剧情" className={`${fieldClass} min-h-[72px]`} />
<textarea value={draft.scene.product ?? ""} onChange={(e) => patchScene({ product: e.target.value })} placeholder="产品融入" className={`${fieldClass} min-h-[72px]`} />
<textarea value={draft.scene.action ?? ""} onChange={(e) => patchScene({ action: e.target.value })} placeholder="动作 / 镜头" className={`${fieldClass} min-h-[72px]`} />
</div>
<div className="mt-2 flex justify-between gap-2">
<label className="flex h-10 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={draft.scene.duration || 5} onChange={(e) => patchScene({ 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>
</div>
</SegmentBand>
<SegmentBand icon={<Package className="h-4 w-4" />} title="每个分镜需要的关键元素">
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<select
value={draft.frameIndex ?? ""}
onChange={(e) => onPatch({ frameIndex: e.target.value ? Number(e.target.value) : null })}
disabled={!job?.frames.length}
className={controlClass}
>
<option value=""></option>
{job?.frames.map((frame, index) => <option key={frame.index} value={frame.index}>{frameLabel(frame, index)}</option>)}
</select>
{boundFrame ? (
<div className="flex items-center gap-3 rounded-md border border-white/10 bg-black/28 p-2">
<img src={effectiveFrameUrl(job!.id, boundFrame)} alt="绑定关键帧" className="h-16 w-24 rounded-md object-cover" />
<span className="text-[12px] text-white/52">{boundFrame.description?.scene || "已绑定关键帧,可保存并生成视频。"}</span>
</div>
) : (
<div className="rounded-md border border-dashed border-white/12 bg-black/25 p-3 text-[12px] text-white/38"></div>
)}
</div>
</SegmentBand>
<SegmentBand icon={<Film className="h-4 w-4" />} title="视频生成">
<div className="grid gap-2 lg:grid-cols-[220px_220px]">
<select value={model} onChange={(e) => setModel(e.target.value as VideoModel)} 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 || draft.frameIndex === null} onClick={generateVideo}>
{generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</ActionButton>
</div>
</SegmentBand>
</div>
</article>
)
}
function SegmentBand({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
return (
<section className="rounded-lg border border-white/10 bg-black/24 p-3">
<div className="mb-2">
<SectionTitle icon={icon} title={title} />
</div>
{children}
</section>
)
}
function AudioStepSummary({ job, audioReady }: { job: Job | null; audioReady: boolean }) {
const downloading = !!job && ["created", "downloading"].includes(job.status)
const audioRunning = !!job && (job.status === "transcribing" || job.audio_script?.status === "rewriting")
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-black/35 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<PanelRight className="h-4 w-4 shrink-0 text-rose-200" />
<div className="min-w-0">
<div className="text-[13px] font-semibold text-white"></div>
<div className="truncate text-[11px] text-white/40">
{job?.message || "等待素材输入;完成后再进入分镜规划和素材生成。"}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-white/52">
<Requirement label="下载" ready={!!job?.video_url} detail={downloading ? "running" : job?.video_url ? "ready" : "wait"} />
<Requirement label="音频" ready={!!job?.source_audio_url} detail={audioRunning ? "running" : job?.source_audio_url ? "ready" : "wait"} />
<Requirement label="文案" ready={audioReady} detail={audioReady ? `${job?.transcript.length ?? 0}` : "wait"} />
</div>
</div>
)
}
function ComposeSummary({
audioReady,
selectedVideoCount,
generatedVideoCount,
}: {
audioReady: boolean
selectedVideoCount: number
generatedVideoCount: number
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-black/35 px-3 py-2">
<div className="flex items-center gap-2">
<PanelRight className="h-4 w-4 text-rose-200" />
<div>
<div className="text-[13px] font-semibold text-white"></div>
<div className="text-[11px] text-white/40">广</div>
</div>
</div>
<div className="flex items-center gap-2 text-[11px] text-white/52">
<Requirement label="音频" ready={audioReady} detail={audioReady ? "已生成" : "待解析"} />
<Requirement label="候选" ready={generatedVideoCount > 0} detail={`${generatedVideoCount}`} />
<Requirement label="已选" ready={selectedVideoCount > 0} detail={`${selectedVideoCount}`} />
<button type="button" disabled className="inline-flex h-10 cursor-not-allowed items-center justify-center gap-2 rounded-md border border-white/10 bg-white/[0.04] px-3 text-[12px] font-semibold text-white/34">
<Film className="h-4 w-4" />
</button>
</div>
</div>
)
}
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.video_url ? "ready" : "-"} compact />
<Metric label="文案" value={job.audio_script?.source_text || job.transcript.length ? "ready" : "-"} compact />
<Metric label="段落" value={`${job.transcript.length}`} 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>
)
}
function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
return (
<h3 className="flex items-center gap-2 text-[13px] font-semibold text-white">
<span className="text-rose-200">{icon}</span>
{title}
</h3>
)
}
function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) {
return (
<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>
)
}
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-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>
)
}
function EmptyState({ text }: { text: string }) {
return (
<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>
)
}
function Requirement({ label, ready, detail }: { label: string; ready: boolean; detail: string }) {
return (
<div className="flex h-10 min-w-0 items-center gap-2 rounded-md border border-white/10 bg-black/28 px-2">
{ready ? <Check className="h-3.5 w-3.5 shrink-0 text-emerald-200" /> : <Circle className="h-3.5 w-3.5 shrink-0 text-white/38" />}
<span className="shrink-0 whitespace-nowrap">{label}</span>
<span className="min-w-0 truncate font-mono text-[11px] text-white/42">{detail}</span>
</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-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>
<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>
)
}