feat: clarify ad recreation workflow steps

This commit is contained in:
2026-05-18 14:38:21 +08:00
parent b5b1e43624
commit 665a0efca6
2 changed files with 291 additions and 28 deletions

File diff suppressed because one or more lines are too long

View File

@@ -112,6 +112,16 @@ type SubjectPlanningRef = ImageRef & { view: string; roleHint: string }
type SubjectStyleMode = "transparent_human" | "source_actor"
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "visualPlan" | "firstFramePlan" | "lastFramePlan" | "productIntegration" | "productPlacement">>
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
type WorkflowStepStatus = "blocked" | "pending" | "running" | "ready" | "paused"
type WorkflowStep = {
id: WorkflowStepId
no: string
title: string
detail: string
judge: string
status: WorkflowStepStatus
}
const VISUAL_MODE_OPTIONS: Array<{ value: StoryboardVisualMode; label: string; description: string }> = [
{ value: "person_only", label: "人物/情绪", description: "只拍人物、状态、痛点或口播,不强制露产品。" },
@@ -297,6 +307,131 @@ function countSubjectAssetViews(job: Job | null) {
0)
}
function countEndpointFramePairs(job: Job | null) {
if (!job) return 0
return job.frames.filter((frame) => endpointAssetRef(frame, "first_frame") && endpointAssetRef(frame, "last_frame")).length
}
function stepStatus({ ready, running, blocked, paused }: { ready?: boolean; running?: boolean; blocked?: boolean; paused?: boolean }): WorkflowStepStatus {
if (running) return "running"
if (ready) return "ready"
if (paused) return "paused"
if (blocked) return "blocked"
return "pending"
}
function buildWorkflowSteps({
job,
submitting,
audioReady,
audioRunning,
transcriptCount,
visualReady,
visualRunning,
subjectAssetCount,
productAssetCount,
endpointFramePairCount,
generatedVideoCount,
}: {
job: Job | null
submitting: boolean
audioReady: boolean
audioRunning: boolean
transcriptCount: number
visualReady: boolean
visualRunning: boolean
subjectAssetCount: number
productAssetCount: number
endpointFramePairCount: number
generatedVideoCount: number
}): WorkflowStep[] {
const hasSourceVideo = !!job?.video_url
const downloading = !!job && ["created", "downloading"].includes(job.status)
const storyboardReady = transcriptCount > 0
const endpointTargetCount = Math.max(transcriptCount, 0)
return [
{
id: "input",
no: "01",
title: "素材输入",
detail: job ? `当前 ${shortId(job.id)}` : "待链接/上传",
judge: "有当前素材任务即通过;输入框只负责创建或切换任务。",
status: stepStatus({ ready: !!job, running: submitting }),
},
{
id: "source",
no: "02",
title: "源视频下载",
detail: hasSourceVideo ? "源视频已就绪" : downloading ? "下载中" : "待下载",
judge: "job.video_url 存在即通过created/downloading 视为运行中。",
status: stepStatus({ ready: hasSourceVideo, running: downloading, blocked: !job }),
},
{
id: "audio",
no: "03",
title: "音频文案",
detail: audioReady ? `${transcriptCount} 段字幕` : "待转写/分析",
judge: "audio_script.source_text 有内容,或 transcript 逐句时间轴有内容即通过。",
status: stepStatus({ ready: audioReady, running: audioRunning, blocked: !hasSourceVideo }),
},
{
id: "visual",
no: "04",
title: "抽帧参考",
detail: visualReady ? `${job?.frames.length ?? 0} 张参考帧` : "待抽帧",
judge: "job.frames.length 大于 0 即通过;这些帧只做主体重构证据。",
status: stepStatus({ ready: visualReady, running: visualRunning, blocked: !hasSourceVideo }),
},
{
id: "subject",
no: "05",
title: "相似主体",
detail: subjectAssetCount ? `${subjectAssetCount} 张白底视图` : "待生成主体",
judge: "关键帧里存在 subject_assets 即通过;生成的是类似创新主体,不复刻原人。",
status: stepStatus({ ready: subjectAssetCount > 0, blocked: !visualReady }),
},
{
id: "product",
no: "06",
title: "产品素材池",
detail: productAssetCount ? `${productAssetCount} 张产品图` : "待上传/识别",
judge: "product_refs 有记录即通过;不限量,但每条视频后续最多挑 6 张相关图。",
status: stepStatus({ ready: productAssetCount > 0, blocked: !job }),
},
{
id: "script",
no: "07",
title: "分镜文案",
detail: storyboardReady ? `${transcriptCount} 条分镜` : "待音频时间轴",
judge: "逐句时间轴生成后进入分镜;新口播可按单段或整片改写。",
status: stepStatus({ ready: storyboardReady, running: audioRunning, blocked: !audioReady }),
},
{
id: "scene",
no: "08",
title: "画面首尾帧",
detail: endpointTargetCount ? `${endpointFramePairCount}/${endpointTargetCount} 组首尾帧` : "待分镜",
judge: "每条分镜先确定场景+人+产品+动作,再生成 asset 类型首帧/尾帧keyframe 不算通过。",
status: stepStatus({ ready: endpointTargetCount > 0 && endpointFramePairCount >= endpointTargetCount, blocked: !storyboardReady }),
},
{
id: "video",
no: "09",
title: "视频候选",
detail: generatedVideoCount ? `${generatedVideoCount} 条历史` : "生成入口暂停",
judge: "当前不直接调视频模型;首尾帧审核后才开放单条或批量提交。",
status: generatedVideoCount > 0 ? "ready" : "paused",
},
]
}
function workflowStepMap(steps: WorkflowStep[]) {
return steps.reduce((acc, step) => {
acc[step.id] = step
return acc
}, {} as Record<WorkflowStepId, WorkflowStep>)
}
function guessSubjectKind(name: string): SubjectKind {
return /人|人物|模特|骨架|身体|脸|手|person|people|human|body|face|hand|character/i.test(name)
? "living"
@@ -1142,6 +1277,21 @@ export function AdRecreationBoard({
const visualReady = (job?.frames.length ?? 0) > 0
const subjectAssetCount = countSubjectAssetViews(job)
const productAssetCount = job?.product_refs?.length ?? 0
const endpointFramePairCount = countEndpointFramePairs(job)
const workflowSteps = buildWorkflowSteps({
job,
submitting: data.submitting,
audioReady,
audioRunning,
transcriptCount,
visualReady,
visualRunning,
subjectAssetCount,
productAssetCount,
endpointFramePairCount,
generatedVideoCount: generatedVideos.length,
})
const workflow = workflowStepMap(workflowSteps)
const statusMessage = job?.message?.startsWith("视频生成已提交")
? "历史候选视频已保留;当前已暂停直接提交视频,先逐条生成并审核首尾帧。"
: job?.message
@@ -1338,9 +1488,12 @@ export function AdRecreationBoard({
</div>
</header>
<WorkflowOrderBar steps={workflowSteps} />
<div className="grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)] gap-3">
<MaterialColumn
data={data}
step={workflow.input}
jobs={jobs}
job={job}
activeJobId={activeJobId}
@@ -1357,7 +1510,7 @@ export function AdRecreationBoard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-rose-500/12 text-rose-100"><Mic className="h-3.5 w-3.5" /></span>
<span className="font-mono text-[12px] text-white/36">02</span>
<WorkflowStepBadge step={workflow.source} compact />
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
</div>
<div className="mt-1 truncate text-[11px] text-white/38" title={statusMessage}>
@@ -1396,10 +1549,10 @@ export function AdRecreationBoard({
</details>
</div>
<div className="mt-2 grid grid-cols-1 gap-1.5 xl:grid-cols-4">
<PipelineLane label="音频文案路" detail={audioReady ? `${transcriptCount} 段字幕可规划` : "字幕 / 讲话人 / 节奏"} ready={audioReady} running={audioRunning} />
<PipelineLane label="视频视觉路" detail={visualReady ? `${job?.frames.length ?? 0} 张参考帧` : "关键帧 / 主体 / 场景"} ready={visualReady} running={visualRunning} />
<PipelineLane label="主体资产" detail={subjectAssetCount ? `${subjectAssetCount} 张主体视图` : "人工选帧后生成"} ready={subjectAssetCount > 0} />
<PipelineLane label="产品资产" detail={productAssetCount ? `${productAssetCount} 张产品图` : "上传后自动识别"} ready={productAssetCount > 0} />
<PipelineLane step={workflow.audio} />
<PipelineLane step={workflow.visual} />
<PipelineLane step={workflow.subject} />
<PipelineLane step={workflow.product} />
</div>
</header>
@@ -1419,6 +1572,10 @@ export function AdRecreationBoard({
onJobUpdate={data.onJobUpdate}
onDeleteVideo={data.onDeleteVideo}
runtimeModels={runtimeModels}
productStep={workflow.product}
scriptStep={workflow.script}
sceneStep={workflow.scene}
videoStep={workflow.video}
/>
</div>
</section>
@@ -1430,6 +1587,7 @@ export function AdRecreationBoard({
function MaterialColumn({
data,
step,
jobs,
job,
activeJobId,
@@ -1440,6 +1598,7 @@ function MaterialColumn({
onStartProduction,
}: {
data: NodeData
step: WorkflowStep
jobs: Job[]
job: Job | null
activeJobId: string | null
@@ -1454,7 +1613,7 @@ function MaterialColumn({
<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>
<WorkflowStepBadge step={step} compact />
</div>
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
<p className="mt-1 text-[12px] leading-snug text-white/42"></p>
@@ -2192,12 +2351,20 @@ function AudioStoryboardPlanPanel({
onJobUpdate,
onDeleteVideo,
runtimeModels,
productStep,
scriptStep,
sceneStep,
videoStep,
}: {
job: Job | null
selectedFrames: Set<number>
onJobUpdate?: (job: Job) => void
onDeleteVideo?: (videoId: string) => void
runtimeModels?: RuntimeModels
productStep: WorkflowStep
scriptStep: WorkflowStep
sceneStep: WorkflowStep
videoStep: WorkflowStep
}) {
const [storyboardSaveBusyRow, setStoryboardSaveBusyRow] = useState<number | null>(null)
const [batchStoryboardSaveBusy, setBatchStoryboardSaveBusy] = useState(false)
@@ -2630,7 +2797,10 @@ function AudioStoryboardPlanPanel({
<section className="mt-3 rounded-lg border border-white/10 bg-black/28 p-2.5">
<div className="mb-2 flex items-start justify-between gap-3">
<div>
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="信息流复刻分镜工作台" />
<div className="flex flex-wrap items-center gap-2">
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="信息流复刻分镜工作台" />
<WorkflowStepBadge step={scriptStep} compact />
</div>
<p className="mt-1 text-[11px] leading-snug text-white/42">/</p>
</div>
<div className="grid shrink-0 grid-cols-3 gap-2 text-[11px] text-white/45">
@@ -2645,6 +2815,7 @@ function AudioStoryboardPlanPanel({
<div className="min-w-0">
<div className="flex items-center gap-2">
<SectionTitle icon={<Package className="h-4 w-4" />} title="同一产品素材池 / 视角标注" />
<WorkflowStepBadge step={productStep} compact />
<ModelTrace trace={productModelTrace(runtimeModels)} compact />
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">{productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}</span>
{(productAnalyzing || productAngleBusy) && (
@@ -2778,7 +2949,7 @@ function AudioStoryboardPlanPanel({
<p className="line-clamp-2 text-[10.5px] leading-snug" title={row.source}>{row.source}</p>
</StoryboardPlanCell>
<StoryboardPlanCell label="新口播文案">
<StoryboardPlanCell label={`${scriptStep.no} 新口播文案`}>
<textarea
value={copyText}
onChange={(event) => patchRowCopy(row.index, event.target.value)}
@@ -2795,7 +2966,7 @@ function AudioStoryboardPlanPanel({
</button>
</StoryboardPlanCell>
<StoryboardPlanCell label="画面规划 / 产品融入">
<StoryboardPlanCell label={`${sceneStep.no} 画面规划 / 产品融入`}>
<div className="grid gap-1.5">
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-1.5">
<select
@@ -2864,7 +3035,7 @@ function AudioStoryboardPlanPanel({
<div className="grid gap-1.5 md:grid-cols-[minmax(0,1fr)_88px_88px]">
<div className="rounded border border-white/10 bg-black/24 px-2 py-1.5 text-[10px] leading-snug text-white/42">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-white/54"></span>
<span className="text-white/54">{sceneStep.no} </span>
<span className={endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") ? "text-emerald-100/75" : "text-amber-100/72"}>
{endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") ? "可进入视频候选" : "先看图再生视频"}
</span>
@@ -2912,7 +3083,7 @@ function AudioStoryboardPlanPanel({
</div>
</StoryboardPlanCell>
<StoryboardPlanCell label="视频候选 / 待生成" className="xl:border-r-0">
<StoryboardPlanCell label={`${videoStep.no} 视频候选 / 待生成`} className="xl:border-r-0">
<StoryboardVideoSlots
job={job}
videos={rowVideos}
@@ -3910,6 +4081,74 @@ function Metric({ label, value, compact }: { label: string; value: string; compa
)
}
const workflowStatusLabels: Record<WorkflowStepStatus, string> = {
blocked: "未解锁",
pending: "待处理",
running: "运行中",
ready: "已通过",
paused: "已暂停",
}
function workflowStatusClass(status: WorkflowStepStatus) {
if (status === "ready") return "border-emerald-300/25 bg-emerald-400/10 text-emerald-100"
if (status === "running") return "border-cyan-300/25 bg-cyan-400/10 text-cyan-100"
if (status === "paused") return "border-amber-300/22 bg-amber-300/[0.08] text-amber-100/76"
if (status === "blocked") return "border-white/8 bg-white/[0.025] text-white/32"
return "border-white/10 bg-white/[0.035] text-white/45"
}
function workflowStatusIcon(status: WorkflowStepStatus) {
if (status === "running") return <Loader2 className="h-3 w-3 animate-spin" />
if (status === "ready") return <Check className="h-3 w-3" />
if (status === "paused") return <AlertTriangle className="h-3 w-3" />
return <Circle className="h-2.5 w-2.5" />
}
function WorkflowOrderBar({ steps }: { steps: WorkflowStep[] }) {
return (
<div className="mb-3 shrink-0 rounded-lg border border-white/10 bg-white/[0.035] p-2">
<div className="mb-1.5 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-[11px] font-semibold text-white/66">
<PanelRight className="h-3.5 w-3.5 text-rose-200" />
</div>
<div className="truncate text-[10.5px] text-white/36"></div>
</div>
<div className="grid grid-cols-[repeat(9,minmax(116px,1fr))] gap-1.5 overflow-x-auto">
{steps.map((step) => (
<div
key={step.id}
title={`判定:${step.judge}`}
className={`min-h-[58px] rounded-md border px-2 py-1.5 ${workflowStatusClass(step.status)}`}
>
<div className="flex items-center justify-between gap-1">
<div className="min-w-0 truncate">
<span className="mr-1 font-mono text-[11px]">{step.no}</span>
<span className="text-[11px] font-semibold">{step.title}</span>
</div>
{workflowStatusIcon(step.status)}
</div>
<div className="mt-1 truncate text-[10px] opacity-80">{step.detail}</div>
<div className="mt-0.5 truncate text-[9.5px] opacity-60">{step.judge}</div>
</div>
))}
</div>
</div>
)
}
function WorkflowStepBadge({ step, compact }: { step: WorkflowStep; compact?: boolean }) {
return (
<span
title={`判定:${step.judge}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-md border font-mono ${workflowStatusClass(step.status)} ${compact ? "px-1.5 py-0.5 text-[10.5px]" : "px-2 py-1 text-[11px]"}`}
>
<span>{step.no}</span>
{!compact && <span className="font-sans">{step.title}</span>}
</span>
)
}
function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
return (
<h3 className="flex items-center gap-2 text-[13px] font-semibold text-white">
@@ -3919,15 +4158,19 @@ function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
)
}
function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) {
function WorkflowStatusPill({ status }: { status: WorkflowStepStatus }) {
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 className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] ${workflowStatusClass(status)}`}>
{workflowStatusIcon(status)}
{workflowStatusLabels[status]}
</span>
)
}
function StatusPill({ ready, running }: { ready: boolean; running?: boolean }) {
return <WorkflowStatusPill status={stepStatus({ ready, running })} />
}
function ActionButton({
children,
disabled,
@@ -3969,14 +4212,17 @@ function Requirement({ label, ready, detail }: { label: string; ready: boolean;
)
}
function PipelineLane({ label, detail, ready, running }: { label: string; detail: string; ready: boolean; running?: boolean }) {
function PipelineLane({ step }: { step: WorkflowStep }) {
return (
<div className="flex min-h-9 items-center justify-between gap-2 rounded-md border border-white/10 bg-black/24 px-2.5 py-1.5">
<div className="min-w-0">
<div className="truncate text-[11px] font-semibold text-white/64">{label}</div>
<div className="mt-0.5 truncate text-[10px] text-white/34" title={detail}>{detail}</div>
<div className="truncate text-[11px] font-semibold text-white/64">
<span className="mr-1 font-mono text-white/38">{step.no}</span>
{step.title}
</div>
<div className="mt-0.5 truncate text-[10px] text-white/34" title={`判定:${step.judge}`}>{step.detail}</div>
</div>
<StatusPill ready={ready} running={running} />
<WorkflowStatusPill status={step.status} />
</div>
)
}