auto-save 2026-05-17 11:55 (~1, -1)
This commit is contained in:
@@ -1,31 +1,5 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "237531f",
|
||||
"message": "auto-save 2026-05-14 17:04 (~1)",
|
||||
"ts": "2026-05-14T17:04:31+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:04 (~1)",
|
||||
"ts": "2026-05-14T09:06:14Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:04 (~1)",
|
||||
"ts": "2026-05-14T09:08:43Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "96e0203",
|
||||
"message": "auto-save 2026-05-14 17:09 (~1)",
|
||||
"ts": "2026-05-14T17:10:04+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "3ab1683",
|
||||
@@ -3269,6 +3243,31 @@
|
||||
"message": "auto-save 2026-05-17 11:23 (~3)",
|
||||
"hash": "201abc6",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-17T11:27:46+08:00",
|
||||
"type": "commit",
|
||||
"message": "refactor: switch ad workflow to horizontal kanban",
|
||||
"hash": "0203a09",
|
||||
"files_changed": 6
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-17T03:28:23Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: switch ad workflow to horizontal kanban",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-17T03:38:23Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: switch ad workflow to horizontal kanban",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-17T03:48:23Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: switch ad workflow to horizontal kanban",
|
||||
"files_changed": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,852 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
AlertTriangle, Check, Circle, Film, FileText, Image as ImageIcon, Link2, Loader2,
|
||||
Mic, Package, PanelRight, Play, Plus, Scissors, Sparkles, Trash2, Upload, Wand2,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
type FrameExtractQuality,
|
||||
type FrameExtractTarget,
|
||||
type GeneratedVideo,
|
||||
type Job,
|
||||
type KeyFrame,
|
||||
type StoryboardScene,
|
||||
addElement,
|
||||
apiAssetUrl,
|
||||
cutoutElement,
|
||||
effectiveFrameUrl,
|
||||
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
|
||||
|
||||
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 "导入素材后,先解析音频,再把产品内容改写成新的分镜文字。"
|
||||
const rewritten = job.audio_script?.rewritten_text?.trim()
|
||||
if (rewritten) return rewritten
|
||||
if (job.transcript?.length) return job.transcript.slice(0, 5).map((item) => item.en || item.zh).join(" ")
|
||||
return "暂无音频文案。解析后这里会作为新剧情和分镜文字的依据。"
|
||||
}
|
||||
|
||||
function storyboardFrames(job: Job | null, selectedFrames: KeyFrame[]) {
|
||||
if (!job) return []
|
||||
if (selectedFrames.length > 0) return selectedFrames
|
||||
return [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
|
||||
export function AdRecreationBoard({
|
||||
data,
|
||||
onGenerateVideo,
|
||||
}: {
|
||||
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 [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
|
||||
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 framesForStoryboard = storyboardFrames(job, selectedFrames)
|
||||
const generatedVideos = job?.generated_videos ?? []
|
||||
const audioReady = !!job?.audio_script?.rewritten_text?.trim()
|
||||
const tone = statusTone(job)
|
||||
|
||||
const submitUrl = () => {
|
||||
const trimmed = url.trim()
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
const generateElementForFrame = async (frame: KeyFrame) => {
|
||||
if (!job) return
|
||||
setElementBusyFrame(frame.index)
|
||||
try {
|
||||
const existing = frame.elements?.[0]
|
||||
if (existing) {
|
||||
const updated = await cutoutElement(job.id, frame.index, existing.id)
|
||||
data.onJobUpdate(updated)
|
||||
toast.success(`已生成元素:${existing.name_zh || existing.name_en || "主体"}`)
|
||||
return
|
||||
}
|
||||
|
||||
const firstObject = frame.description?.objects?.[0]
|
||||
const name = firstObject?.name?.trim() || "主体"
|
||||
const added = await addElement(job.id, frame.index, {
|
||||
name_zh: name,
|
||||
name_en: name,
|
||||
position: firstObject?.position,
|
||||
source: "manual",
|
||||
})
|
||||
const latestFrame = added.frames.find((item) => item.index === frame.index)
|
||||
const newElement = latestFrame?.elements?.[latestFrame.elements.length - 1]
|
||||
if (!newElement) {
|
||||
data.onJobUpdate(added)
|
||||
toast.success(`已登记元素:${name}`)
|
||||
return
|
||||
}
|
||||
const updated = await cutoutElement(job.id, frame.index, newElement.id)
|
||||
data.onJobUpdate(updated)
|
||||
toast.success(`已生成元素:${name}`)
|
||||
} catch (e) {
|
||||
toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setElementBusyFrame(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative z-20 h-screen w-screen overflow-hidden bg-black text-white">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_9%_0%,rgba(225,29,72,0.18),transparent_28%),radial-gradient(circle_at_60%_0%,rgba(14,165,233,0.08),transparent_26%)]" />
|
||||
<div className="relative flex h-full flex-col px-4 py-4">
|
||||
<header className="mb-3 flex items-center justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/40">feed ad remake board</div>
|
||||
<h1 className="mt-1 text-[22px] font-semibold leading-tight text-white">信息流广告横向生产看板</h1>
|
||||
</div>
|
||||
<div className="grid min-w-[520px] grid-cols-5 gap-2 text-[11px] text-white/48">
|
||||
<Metric label="素材" value={`${jobs.length}`} />
|
||||
<Metric label="当前" value={shortId(activeJobId)} />
|
||||
<Metric label="抽帧" value={`${job?.frames.length ?? 0}`} />
|
||||
<Metric label="分镜" value={`${framesForStoryboard.length}`} />
|
||||
<Metric label="片段" value={`${generatedVideos.length}`} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-x-auto pb-2">
|
||||
<div className="grid h-full min-w-[1280px] grid-cols-[300px_360px_minmax(390px,1fr)_320px] gap-3">
|
||||
<BoardColumn
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
step="01"
|
||||
title="素材输入"
|
||||
subtitle="一个素材就是一次文件任务"
|
||||
>
|
||||
<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="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="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"
|
||||
/>
|
||||
)}
|
||||
</BoardColumn>
|
||||
|
||||
<BoardColumn
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
step="02"
|
||||
title="音频解析 / 新分镜文案"
|
||||
subtitle="按产品内容改写,分镜自上而下排列"
|
||||
>
|
||||
<div className="rounded-lg border border-white/10 bg-black/32 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<SectionTitle icon={<Mic className="h-4 w-4" />} title="音频文案" />
|
||||
<StatusPill ready={audioReady} running={job?.status === "transcribing" || job?.audio_script?.status === "rewriting"} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ActionButton disabled={!job?.video_url || job.status === "transcribing"} onClick={() => data.onTranscribeAudio?.(job?.id)}>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
解析音频
|
||||
</ActionButton>
|
||||
<ActionButton disabled={!job?.source_audio_url && !job?.audio_script?.voice_url} variant="ghost" onClick={() => data.onOpenAudioStrip?.(job?.id)}>
|
||||
打开音轨
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="mt-2 max-h-28 overflow-y-auto rounded-md border border-white/10 bg-black/35 p-2 text-[12px] leading-relaxed text-white/62">
|
||||
{audioPreview(job)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
||||
{job && framesForStoryboard.length > 0 ? framesForStoryboard.map((frame, order) => (
|
||||
<SceneRow
|
||||
key={`${job.id}:${frame.index}`}
|
||||
job={job}
|
||||
frame={frame}
|
||||
order={order}
|
||||
selected={data.selectedFrames.has(frame.index)}
|
||||
onToggle={() => data.onToggleFrame(frame.index)}
|
||||
onJobUpdate={data.onJobUpdate}
|
||||
/>
|
||||
)) : (
|
||||
<EmptyState text="抽帧后,这里会按时间从上到下列出新分镜文字。" />
|
||||
)}
|
||||
</div>
|
||||
</BoardColumn>
|
||||
|
||||
<BoardColumn
|
||||
icon={<Scissors className="h-4 w-4" />}
|
||||
step="03"
|
||||
title="视频关键元素 / 抽帧生成"
|
||||
subtitle="关键帧横向展开,直接生成元素和片段"
|
||||
>
|
||||
<div className="rounded-lg border border-white/10 bg-black/32 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<SectionTitle icon={<Scissors className="h-4 w-4" />} title="关键帧抽取" />
|
||||
<StatusPill ready={!!job?.frames.length} running={data.analyzing || job?.status === "splitting"} />
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr_72px] gap-2">
|
||||
<select
|
||||
value={job ? data.frameTargets[job.id] ?? "transparent_human" : "balanced"}
|
||||
onChange={(e) => job && data.onFrameTargetChange(job.id, e.target.value as FrameExtractTarget)}
|
||||
disabled={!job}
|
||||
className={controlClass}
|
||||
>
|
||||
{TARGETS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={job ? data.frameQualities[job.id] ?? "auto" : "auto"}
|
||||
onChange={(e) => job && data.onFrameQualityChange(job.id, e.target.value as FrameExtractQuality)}
|
||||
disabled={!job}
|
||||
className={controlClass}
|
||||
>
|
||||
{QUALITIES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={job ? data.frameCounts[job.id] ?? 12 : 12}
|
||||
onChange={(e) => job && data.onFrameCountChange(job.id, Number(e.target.value) || 12)}
|
||||
disabled={!job}
|
||||
className={`${controlClass} text-center`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<ActionButton disabled={!job || data.analyzing} onClick={() => data.onAnalyze({ mode: job?.frames.length ? "append" : "replace" })}>
|
||||
{data.analyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||||
{job?.frames.length ? "追加抽帧" : "开始抽帧"}
|
||||
</ActionButton>
|
||||
<ActionButton disabled={!job?.frames.length} variant="ghost" onClick={selectAllFrames}>全选分镜</ActionButton>
|
||||
<ActionButton disabled={!selectedFrames.length} variant="ghost" onClick={clearFrameSelection}>清空选择</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-x-auto">
|
||||
<div className="flex h-full min-w-max gap-3 pr-2">
|
||||
{job?.frames.length ? job.frames.map((frame, index) => (
|
||||
<ElementFrameCard
|
||||
key={frame.index}
|
||||
job={job}
|
||||
frame={frame}
|
||||
order={index}
|
||||
selected={data.selectedFrames.has(frame.index)}
|
||||
busy={elementBusyFrame === frame.index}
|
||||
onToggle={() => data.onToggleFrame(frame.index)}
|
||||
onGenerateElement={() => generateElementForFrame(frame)}
|
||||
onGenerateVideo={onGenerateVideo}
|
||||
/>
|
||||
)) : (
|
||||
<div className="w-[420px]">
|
||||
<EmptyState text="抽帧后,关键帧会横向排列;每张帧卡可生成元素、选入分镜、生成候选片段。" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BoardColumn>
|
||||
|
||||
<BoardColumn
|
||||
icon={<Film className="h-4 w-4" />}
|
||||
step="04"
|
||||
title="视频合成"
|
||||
subtitle="音频和候选视频合成完整广告"
|
||||
>
|
||||
<div className="rounded-lg border border-white/10 bg-black/32 p-3">
|
||||
<SectionTitle icon={<PanelRight className="h-4 w-4" />} title="合成输入" />
|
||||
<div className="mt-3 grid gap-2 text-[12px] text-white/58">
|
||||
<Requirement label="音频文案" ready={audioReady} detail={audioReady ? "已生成" : "待解析"} />
|
||||
<Requirement label="候选片段" ready={selectedVideoIds.size > 0} detail={`已选 ${selectedVideoIds.size}`} />
|
||||
<Requirement label="合成接口" ready={false} detail="待接入" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="mt-3 inline-flex h-10 w-full cursor-not-allowed items-center justify-center gap-2 rounded-md border border-white/10 bg-white/[0.04] text-[13px] font-semibold text-white/34"
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
合成完整视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
||||
{job && generatedVideos.length > 0 ? generatedVideos.map((video) => (
|
||||
<VideoCandidate
|
||||
key={video.id}
|
||||
job={job}
|
||||
video={video}
|
||||
selected={selectedVideoIds.has(video.id)}
|
||||
onToggle={() => toggleVideo(video.id)}
|
||||
onDelete={() => data.onDeleteVideo?.(video.id)}
|
||||
/>
|
||||
)) : (
|
||||
<EmptyState text="生成候选片段后,在这里勾选要合成的版本。" />
|
||||
)}
|
||||
</div>
|
||||
</BoardColumn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BoardColumn({
|
||||
icon,
|
||||
step,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
step: string
|
||||
title: string
|
||||
subtitle: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
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 justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-rose-500/12 text-rose-100">{icon}</span>
|
||||
<span className="font-mono text-[12px] text-white/36">{step}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-[15px] font-semibold leading-tight text-white">{title}</h2>
|
||||
<p className="mt-1 text-[12px] leading-snug text-white/42">{subtitle}</p>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MaterialCard({
|
||||
job,
|
||||
index,
|
||||
active,
|
||||
onClick,
|
||||
onDelete,
|
||||
}: {
|
||||
job: Job
|
||||
index: number
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
onDelete?: () => void
|
||||
}) {
|
||||
const tone = statusTone(job)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`group w-full rounded-lg border p-3 text-left transition ${active ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/28 hover:border-white/24 hover:bg-white/[0.045]"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[12px] text-white/78">素材 {String(index + 1).padStart(2, "0")}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-white/42">
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span className="truncate">{job.url || shortId(job.id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-md border px-2 py-1 text-[11px] ${tone.className}`}>{tone.label}</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px] text-white/44">
|
||||
<Metric label="帧" value={`${job.frames.length}`} compact />
|
||||
<Metric label="音频" value={job.audio_script?.rewritten_text ? "ready" : "-"} compact />
|
||||
<Metric label="片段" value={`${job.generated_videos?.length ?? 0}`} compact />
|
||||
</div>
|
||||
{onDelete && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => { event.stopPropagation(); onDelete() }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onDelete()
|
||||
}
|
||||
}}
|
||||
className="mt-3 hidden h-8 items-center justify-center gap-1 rounded-md border border-white/10 text-[11px] text-white/50 transition hover:border-rose-300/40 hover:text-rose-200 group-hover:flex"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除素材
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value, compact }: { label: string; value: string; compact?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-md border border-white/10 bg-black/35 ${compact ? "px-2 py-1" : "px-2 py-1.5"}`}>
|
||||
<div>{label}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[12px] text-white/78">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 SceneRow({
|
||||
job,
|
||||
frame,
|
||||
order,
|
||||
selected,
|
||||
onToggle,
|
||||
onJobUpdate,
|
||||
}: {
|
||||
job: Job
|
||||
frame: KeyFrame
|
||||
order: number
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
onJobUpdate: (job: Job) => void
|
||||
}) {
|
||||
const [scene, setScene] = useState<StoryboardScene>(() => ({ ...emptyScene(), ...(frame.storyboard ?? {}) }))
|
||||
const [saving, setSaving] = 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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`rounded-lg border p-2.5 transition ${selected ? "border-rose-400/60 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="relative h-16 w-10 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black"
|
||||
aria-label={selected ? "取消选中分镜" : "选中分镜"}
|
||||
>
|
||||
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="h-full w-full object-cover" />
|
||||
<span className="absolute right-0.5 top-0.5 rounded-full bg-black/70 p-0.5">
|
||||
{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}
|
||||
</span>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-mono text-[12px] text-white/78">{frameLabel(frame, order)}</div>
|
||||
<label className="flex h-8 items-center gap-1 rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white/45">
|
||||
秒
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={0.5}
|
||||
value={scene.duration || 5}
|
||||
onChange={(e) => patch({ duration: Number(e.target.value) || 5 })}
|
||||
className="w-12 bg-transparent text-center font-mono text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-white/42">{frame.description?.scene || "等待生成新的分镜文字"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<textarea
|
||||
value={scene.scene ?? ""}
|
||||
onChange={(e) => patch({ scene: e.target.value })}
|
||||
placeholder="新剧情:根据原音频文案和产品内容,这一镜要讲什么"
|
||||
className={`${fieldClass} min-h-[52px]`}
|
||||
/>
|
||||
<textarea
|
||||
value={scene.product ?? ""}
|
||||
onChange={(e) => patch({ product: e.target.value })}
|
||||
placeholder="产品融入:SKG 产品在哪里出现,怎么被使用"
|
||||
className={`${fieldClass} min-h-[52px]`}
|
||||
/>
|
||||
<textarea
|
||||
value={scene.action ?? ""}
|
||||
onChange={(e) => patch({ action: e.target.value })}
|
||||
placeholder="动作 / 镜头:首尾变化、手部动作、运镜节奏"
|
||||
className={`${fieldClass} min-h-[52px]`}
|
||||
/>
|
||||
<ActionButton variant="ghost" disabled={saving} onClick={save}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
保存分镜文字
|
||||
</ActionButton>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function ElementFrameCard({
|
||||
job,
|
||||
frame,
|
||||
order,
|
||||
selected,
|
||||
busy,
|
||||
onToggle,
|
||||
onGenerateElement,
|
||||
onGenerateVideo,
|
||||
}: {
|
||||
job: Job
|
||||
frame: KeyFrame
|
||||
order: number
|
||||
selected: boolean
|
||||
busy: boolean
|
||||
onToggle: () => void
|
||||
onGenerateElement: () => void
|
||||
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||
}) {
|
||||
const [model, setModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance")
|
||||
const [generatingVideo, setGeneratingVideo] = useState(false)
|
||||
const elements = frame.elements ?? []
|
||||
const generatedImages = frame.generated_images ?? []
|
||||
const objectNames = frame.description?.objects?.slice(0, 4).map((item) => item.name).filter(Boolean) ?? []
|
||||
const elementPreviews = elements
|
||||
.map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) }))
|
||||
.filter((item): item is { element: typeof elements[number]; src: string } => !!item.src)
|
||||
|
||||
const generateVideo = async () => {
|
||||
setGeneratingVideo(true)
|
||||
try {
|
||||
await onGenerateVideo(frame.index, { ...emptyScene(), ...(frame.storyboard ?? {}) }, model)
|
||||
} finally {
|
||||
setGeneratingVideo(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`flex h-full w-[280px] shrink-0 flex-col rounded-lg border p-2.5 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="group relative aspect-[9/16] w-full overflow-hidden rounded-md border border-white/10 bg-black"
|
||||
aria-label={selected ? "取消选中关键帧" : "选中关键帧"}
|
||||
>
|
||||
<img src={effectiveFrameUrl(job.id, frame)} alt={frameLabel(frame, order)} className="h-full w-full object-cover opacity-92 transition group-hover:opacity-100" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-2 pt-8">
|
||||
<div className="flex items-center justify-between text-[11px] font-medium text-white">
|
||||
<span>{frameLabel(frame, order)}</span>
|
||||
{selected ? <Check className="h-4 w-4 text-rose-200" /> : <Circle className="h-3.5 w-3.5 text-white/55" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[11px] text-white/44">
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
<span>{elements.filter(hasCutout).length}/{elements.length || objectNames.length || 0} 元素</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px] text-white/44">
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
<span>{generatedImages.length} 图</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{(elements.length ? elements.map((item) => item.name_zh || item.name_en) : objectNames).slice(0, 5).map((name) => (
|
||||
<span key={name} className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/50">{name}</span>
|
||||
))}
|
||||
{!elements.length && !objectNames.length && <span className="text-[11px] text-white/32">暂无识别元素</span>}
|
||||
</div>
|
||||
|
||||
{(elementPreviews.length > 0 || generatedImages.length > 0) && (
|
||||
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">
|
||||
{elementPreviews.slice(0, 4).map(({ element, src }) => (
|
||||
<img key={element.id} src={src} alt={element.name_zh || element.name_en} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
|
||||
))}
|
||||
{generatedImages.slice(0, 4).map((image) => (
|
||||
<img key={image.id} src={generatedImageUrl(job.id, frame.index, image.id)} alt={image.prompt || "生成图"} className="h-14 w-14 shrink-0 rounded-md border border-white/10 bg-black object-cover" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto grid gap-2 pt-3">
|
||||
<ActionButton disabled={busy} variant="ghost" onClick={onGenerateElement}>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
生成元素
|
||||
</ActionButton>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<select value={model} onChange={(e) => setModel(e.target.value as (typeof VIDEO_MODELS)[number]["value"])} className="h-10 rounded-md border border-white/10 bg-black/55 px-2 text-[12px] text-white outline-none">
|
||||
{VIDEO_MODELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
<ActionButton disabled={generatingVideo} onClick={generateVideo}>
|
||||
{generatingVideo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
片段
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function Requirement({ label, ready, detail }: { label: string; ready: boolean; detail: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-md border border-white/10 bg-black/28 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{ready ? <Check className="h-3.5 w-3.5 text-emerald-200" /> : <Circle className="h-3.5 w-3.5 text-white/38" />}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<span className="font-mono text-[11px] text-white/42">{detail}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user