auto-save 2026-05-17 10:56 (+1, ~2)
This commit is contained in:
1299
.memory/worklog.json
1299
.memory/worklog.json
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import {
|
||||
ReactFlow, Background, BackgroundVariant, Controls, MiniMap,
|
||||
ReactFlow, Background, BackgroundVariant, Controls,
|
||||
useNodesState, useEdgesState,
|
||||
type Node, type Edge,
|
||||
} from "@xyflow/react"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import { LayoutGrid } from "lucide-react"
|
||||
import {
|
||||
InputNode, VisualLabNode, AudioNode,
|
||||
ComposeNode, KeyframePanelNode,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
} from "@/components/nodes"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { AudioStrip } from "@/components/audio-strip"
|
||||
import { AdRecreationBoard } from "@/components/ad-recreation-board"
|
||||
import {
|
||||
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
||||
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe,
|
||||
@@ -535,7 +535,7 @@ export default function Home() {
|
||||
})
|
||||
updateJobInList(updated)
|
||||
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
||||
toast.success("视频任务已进入 Video Gen 节点")
|
||||
toast.success("视频任务已进入候选片段")
|
||||
} catch (e) {
|
||||
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
@@ -666,7 +666,7 @@ export default function Home() {
|
||||
if (jobs.length === 0) return
|
||||
// 状态切到 downloaded 时提示用户点解析(仅一次)
|
||||
if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") {
|
||||
toast.info("📥 视频已就绪 — 请点 Input 节点里的「点这里开始解析」按钮", { duration: 6000 })
|
||||
toast.info("视频已就绪,请在左侧看板开始抽帧", { duration: 6000 })
|
||||
}
|
||||
prevStatusRef.current = job?.status ?? null
|
||||
|
||||
@@ -767,7 +767,7 @@ export default function Home() {
|
||||
|
||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
|
||||
const [nodes, setNodes] = useNodesState<Node>(
|
||||
LAYOUT.map((n) => {
|
||||
const s = savedSizes[n.id] ?? {}
|
||||
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 {}
|
||||
}, [nodes])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>(
|
||||
const [, setEdges] = useEdgesState<Edge>(
|
||||
EDGES_RAW.map(([from, to], i) => ({
|
||||
id: `e${i}`, source: from, target: to, animated: false, type: "default",
|
||||
})),
|
||||
@@ -1002,45 +1002,32 @@ export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<div className="canvas-bg" />
|
||||
<main className="relative h-screen w-screen overflow-hidden flex">
|
||||
{/* 自动整理布局 — 主题切换上方,一键恢复默认位置和宽度 */}
|
||||
<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>
|
||||
<main className="relative flex h-screen w-screen overflow-hidden">
|
||||
<AdRecreationBoard data={nodeData} onGenerateVideo={handleQuickGenerateVideo} />
|
||||
|
||||
{/* 右区: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">
|
||||
{clientReady ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
onInit={(instance) => { flowRef.current = instance }}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={NODE_TYPES}
|
||||
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" />
|
||||
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<div className="h-full w-full" suppressHydrationWarning />
|
||||
|
||||
588
web/components/ad-recreation-board.tsx
Normal file
588
web/components/ad-recreation-board.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user