feat: parallelize ad recreation intake
This commit is contained in:
@@ -285,6 +285,13 @@ function countReadySegments(job: Job | null, drafts: DraftSegment[]) {
|
||||
return frameStoryboards + draftCount
|
||||
}
|
||||
|
||||
function countSubjectAssetViews(job: Job | null) {
|
||||
if (!job) return 0
|
||||
return job.frames.reduce((sum, frame) =>
|
||||
sum + (frame.elements ?? []).reduce((inner, element) => inner + (element.subject_assets?.length ?? 0), 0),
|
||||
0)
|
||||
}
|
||||
|
||||
function guessSubjectKind(name: string): SubjectKind {
|
||||
return /人|人物|模特|骨架|身体|脸|手|person|people|human|body|face|hand|character/i.test(name)
|
||||
? "living"
|
||||
@@ -976,6 +983,11 @@ export function AdRecreationBoard({
|
||||
const readySegments = countReadySegments(job, draftSegments)
|
||||
const transcriptCount = job?.transcript.length ?? 0
|
||||
const backgroundReady = !!job?.audio_script?.background_audio_profile?.trim()
|
||||
const audioRunning = job?.status === "transcribing" || job?.audio_script?.status === "rewriting"
|
||||
const visualRunning = job?.status === "splitting"
|
||||
const visualReady = (job?.frames.length ?? 0) > 0
|
||||
const subjectAssetCount = countSubjectAssetViews(job)
|
||||
const productAssetCount = job?.product_refs?.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
setDraftSegments([])
|
||||
@@ -1197,7 +1209,7 @@ export function AdRecreationBoard({
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModelTrace trace={audioModelTrace(runtimeModels)} compact />
|
||||
<ActionButton disabled={!job?.video_url || job.status === "transcribing"} onClick={() => data.onTranscribeAudio?.(job?.id)}>
|
||||
<ActionButton disabled={!job?.video_url || audioRunning} onClick={() => data.onTranscribeAudio?.(job?.id)}>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
解析音频
|
||||
</ActionButton>
|
||||
@@ -1208,8 +1220,9 @@ export function AdRecreationBoard({
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-white/46">
|
||||
<Requirement label="素材" ready={!!job} detail={job ? shortId(job.id) : "待输入"} />
|
||||
<Requirement label="视频" ready={!!job?.video_url} detail={job?.status === "downloading" ? "下载中" : job?.video_url ? "已就绪" : "待下载"} />
|
||||
<Requirement label="音频" ready={!!job?.source_audio_url} detail={job?.status === "transcribing" ? "解析中" : job?.source_audio_url ? "已提取" : "待提取"} />
|
||||
<Requirement label="音频" ready={!!job?.source_audio_url} detail={audioRunning ? "解析中" : job?.source_audio_url ? "已提取" : "待提取"} />
|
||||
<Requirement label="文案" ready={audioReady} detail={audioReady ? `${transcriptCount} 段` : "待解析"} />
|
||||
<Requirement label="参考帧" ready={visualReady} detail={visualRunning ? "抽帧中" : visualReady ? `${job?.frames.length ?? 0} 张` : "待抽帧"} />
|
||||
</div>
|
||||
|
||||
<details className="group rounded-md border border-white/10 bg-black/28 p-2">
|
||||
@@ -1225,6 +1238,12 @@ export function AdRecreationBoard({
|
||||
</div>
|
||||
</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} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
@@ -1298,7 +1317,7 @@ function MaterialColumn({
|
||||
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"
|
||||
@@ -2148,6 +2167,7 @@ function AudioStoryboardPlanPanel({
|
||||
runtimeModels?: RuntimeModels
|
||||
}) {
|
||||
const [videoBusyRow, setVideoBusyRow] = useState<number | null>(null)
|
||||
const [batchVideoBusy, setBatchVideoBusy] = useState(false)
|
||||
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
|
||||
const [productUploading, setProductUploading] = useState(false)
|
||||
const [productAnalyzing, setProductAnalyzing] = useState(false)
|
||||
@@ -2443,16 +2463,21 @@ function AudioStoryboardPlanPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||||
if (!job || !frame || !onGenerateVideo) return
|
||||
const submitRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame) => {
|
||||
if (!job || !onGenerateVideo) return
|
||||
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
|
||||
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
|
||||
const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, nextFrame, productItems)
|
||||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||||
onJobUpdate?.(updated)
|
||||
await onGenerateVideo(frame.index, scene, "seedance")
|
||||
}
|
||||
|
||||
const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||||
if (!job || !frame || !onGenerateVideo) return
|
||||
setVideoBusyRow(row.index)
|
||||
try {
|
||||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||||
onJobUpdate?.(updated)
|
||||
await onGenerateVideo(frame.index, scene, "seedance")
|
||||
await submitRowVideo(row, frame)
|
||||
} catch (e) {
|
||||
toast.error("生成本条视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
@@ -2460,6 +2485,37 @@ function AudioStoryboardPlanPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const generateAllRowVideos = async () => {
|
||||
if (!job || !onGenerateVideo || !rows.length) return
|
||||
const jobsToSubmit = rows
|
||||
.map((row) => ({ row: planForRow(row, referenceFrameForRow(row)), frame: referenceFrameForRow(row) }))
|
||||
.filter((item): item is { row: AudioStoryboardRow; frame: KeyFrame } => !!item.frame)
|
||||
if (!jobsToSubmit.length) {
|
||||
toast.warning("先完成自动抽帧,或在原版视频上手动补参考帧")
|
||||
return
|
||||
}
|
||||
setBatchVideoBusy(true)
|
||||
let ok = 0
|
||||
let failed = 0
|
||||
try {
|
||||
for (const item of jobsToSubmit) {
|
||||
setVideoBusyRow(item.row.index)
|
||||
try {
|
||||
await submitRowVideo(item.row, item.frame)
|
||||
ok += 1
|
||||
} catch (e) {
|
||||
failed += 1
|
||||
console.warn("批量提交分镜失败", item.row.index, e)
|
||||
}
|
||||
}
|
||||
if (failed) toast.warning(`已提交 ${ok} 条,${failed} 条失败`)
|
||||
else toast.success(`已提交全部 ${ok} 条分镜视频`)
|
||||
} finally {
|
||||
setVideoBusyRow(null)
|
||||
setBatchVideoBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!job) return null
|
||||
|
||||
return (
|
||||
@@ -2578,6 +2634,15 @@ function AudioStoryboardPlanPanel({
|
||||
>
|
||||
还原初稿
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generateAllRowVideos()}
|
||||
disabled={batchVideoBusy || !onGenerateVideo || !rows.length || !orderedFrames.length}
|
||||
className="inline-flex h-9 items-center justify-center gap-1 rounded-md bg-rose-600 px-2.5 text-[11px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{batchVideoBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
一键提交全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
|
||||
@@ -3695,6 +3760,18 @@ function Requirement({ label, ready, detail }: { label: string; ready: boolean;
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineLane({ label, detail, ready, running }: { label: string; detail: string; ready: boolean; running?: boolean }) {
|
||||
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>
|
||||
<StatusPill ready={ready} running={running} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoCandidate({
|
||||
job,
|
||||
video,
|
||||
|
||||
Reference in New Issue
Block a user