diff --git a/.memory/worklog.json b/.memory/worklog.json index f1651a6..30de37b 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,31 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "397bae2", - "message": "auto-save 2026-05-14 17:20 (~1)", - "ts": "2026-05-14T17:21:07+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:20 (~1)", - "ts": "2026-05-14T09:26:14Z", - "type": "session-heartbeat" - }, - { - "files_changed": 1, - "hash": "16c51fc", - "message": "auto-save 2026-05-14 17:26 (~1)", - "ts": "2026-05-14T17:26:40+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:26 (~1)", - "ts": "2026-05-14T09:28:43Z", - "type": "session-heartbeat" - }, { "files_changed": 1, "hash": "8b1e0bd", @@ -3269,6 +3243,31 @@ "message": "auto-save 2026-05-17 12:01 (+1, ~1)", "hash": "7d399b8", "files_changed": 2 + }, + { + "ts": "2026-05-17T12:06:14+08:00", + "type": "commit", + "message": "refactor: merge storyboard workflow into segment board", + "hash": "652a487", + "files_changed": 6 + }, + { + "ts": "2026-05-17T04:08:23Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: merge storyboard workflow into segment board", + "files_changed": 1 + }, + { + "ts": "2026-05-17T04:18:24Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: merge storyboard workflow into segment board", + "files_changed": 1 + }, + { + "ts": "2026-05-17T04:28:24Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 4 项未提交变更 · 最近提交:refactor: merge storyboard workflow into segment board", + "files_changed": 4 } ] } diff --git a/web/app/page.tsx b/web/app/page.tsx index 8d417a9..14492ac 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -19,8 +19,8 @@ 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, - type Job, type ImageRef, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, + deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset, + type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, } from "@/lib/api" import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target" @@ -53,6 +53,12 @@ const FRAME_QUALITY_LABELS: Record = { accurate: "精细", ultra: "极准", } +const DEFAULT_PRODUCT_LIBRARY_IDS = [ + "desktop-skg-product-angle-01", + "desktop-skg-product-angle-02", + "desktop-skg-product-angle-03", + "desktop-skg-product-angle-04", +] const PRODUCT_FUSION_WEARING_PROMPT = [ "Product placement must be physically correct:", @@ -150,6 +156,10 @@ export default function Home() { const [videoPanelDock, setVideoPanelDock] = useState("left") const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0) const [clipboard, setClipboard] = useState(null) + const [productionJobIds, setProductionJobIds] = useState>(new Set()) + const [planningJobIds, setPlanningJobIds] = useState>(new Set()) + const [defaultProductRefsByJob, setDefaultProductRefsByJob] = useState>({}) + const autoTriggeredRef = useRef>(new Set()) const flowRef = useRef(null) const lastVideoPanelFocusKey = useRef("") @@ -201,8 +211,10 @@ export default function Home() { const created = await createJob(url) addJob(created) toast.success(`已创建任务 ${created.id.slice(0, 8)}`) + return created } catch (e) { toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) + return undefined } finally { setSubmitting(false) } @@ -447,6 +459,116 @@ export default function Home() { } }, [activeJobId, jobs, updateJobInList]) + const ensureDefaultProductRefs = useCallback(async (jobId: string) => { + const cached = defaultProductRefsByJob[jobId] + if (cached?.length >= 4) return cached.slice(0, 4) + const refs = await Promise.all(DEFAULT_PRODUCT_LIBRARY_IDS.map((id) => copyProductLibraryAsset(jobId, id))) + setDefaultProductRefsByJob((prev) => ({ ...prev, [jobId]: refs })) + return refs + }, [defaultProductRefsByJob]) + + const buildPlannedScene = useCallback((targetJob: Job, frame: KeyFrame, order: number): StoryboardScene => { + const frames = [...targetJob.frames].sort((a, b) => a.timestamp - b.timestamp) + const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null + const totalDuration = Math.max(targetJob.duration || 0, frames.length * 5, 5) + const duration = Math.max(3.5, Math.min(7.5, totalDuration / Math.max(frames.length, 1))) + const audioLine = targetJob.audio_script?.rewritten_text?.trim() + || targetJob.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ") + || "按原视频说话节奏生成 SKG 产品口播。" + const sceneText = frame.description?.scene?.trim() + || `参考原视频第 ${order + 1} 个关键画面,建立一个可复刻的信息流广告分镜。` + const objectText = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、") + return { + duration: Number(duration.toFixed(1)), + first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` }, + last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null, + subject: objectText ? `优先保留并改造这些可选关键元素:${objectText}。` : "保留原视频里最能驱动剧情的主体动作和镜头关系。", + scene: `${sceneText}\n音频节奏依据:${audioLine.slice(0, 220)}`, + product: "把这一镜改成 SKG 颈部/肩颈按摩仪的信息流广告表达。默认使用 SKG 四张真实产品角度图作为产品真源,产品必须外置佩戴在肩颈位置,不要变成其他物体。", + action: frame.description?.style + ? `沿用原画面的镜头节奏和 ${frame.description.style},动作要从首帧自然过渡到尾帧,突出使用前紧绷、使用后放松。` + : "沿用原视频的讲话/动作节奏,动作要从首帧自然过渡到尾帧,突出使用前紧绷、使用后放松。", + reference_ids: [], + } + }, []) + + const handlePlanStoryboardJob = useCallback(async (jobId: string) => { + if (planningJobIds.has(jobId)) return + const initial = jobs.find((item) => item.id === jobId) + if (!initial || initial.frames.length === 0) return + setPlanningJobIds((prev) => new Set(prev).add(jobId)) + try { + let latest = initial + const frames = [...latest.frames].sort((a, b) => a.timestamp - b.timestamp) + toast.info(`开始扫描关键元素 · ${frames.length} 个分镜`) + for (let order = 0; order < frames.length; order += 1) { + const frame = frames[order] + let currentFrame = latest.frames.find((item) => item.index === frame.index) ?? frame + if (!currentFrame.description) { + latest = await describeFrame(jobId, frame.index) + updateJobInList(latest) + currentFrame = latest.frames.find((item) => item.index === frame.index) ?? currentFrame + } + if (!currentFrame.storyboard) { + const planned = buildPlannedScene(latest, currentFrame, order) + latest = await updateStoryboard(jobId, frame.index, planned) + updateJobInList(latest) + } + } + toast.success("关键元素扫描和分镜初稿已生成") + } catch (e) { + toast.error("分镜规划失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setPlanningJobIds((prev) => { + const next = new Set(prev) + next.delete(jobId) + return next + }) + } + }, [buildPlannedScene, jobs, planningJobIds, updateJobInList]) + + const handleStartProduction = useCallback(async (inputUrl?: string) => { + const trimmed = inputUrl?.trim() + const created = trimmed ? await handleSubmit(trimmed) : undefined + const target = created ?? job + if (!target) { + toast.info("先粘贴视频链接或选择一个素材任务") + return + } + setProductionJobIds((prev) => new Set(prev).add(target.id)) + setAudioStripJobId(target.id) + toast.success("已进入自动生产:下载完成后会抽帧、解析音频并生成分镜初稿") + if (target.video_url && ["downloaded", "frames_extracted", "transcribed", "failed"].includes(target.status)) { + if (!target.frames.length) void handleAnalyzeJob(target.id, { mode: "replace" }) + void handleTranscribeAudio(target.id, { silent: true }) + if (target.frames.length) void handlePlanStoryboardJob(target.id) + } + }, [handleAnalyzeJob, handlePlanStoryboardJob, handleSubmit, handleTranscribeAudio, job]) + + useEffect(() => { + if (productionJobIds.size === 0) return + for (const item of jobs) { + if (!productionJobIds.has(item.id)) continue + const videoReady = !!item.video_url && ["downloaded", "frames_extracted", "transcribed", "failed"].includes(item.status) + if (!videoReady) continue + const audioKey = `${item.id}:audio` + if (!autoTriggeredRef.current.has(audioKey) && item.audio_script?.status !== "rewriting" && !item.audio_script?.rewritten_text) { + autoTriggeredRef.current.add(audioKey) + void handleTranscribeAudio(item.id, { silent: true }) + } + const analyzeKey = `${item.id}:analyze` + if (!autoTriggeredRef.current.has(analyzeKey) && item.frames.length === 0 && item.status !== "splitting") { + autoTriggeredRef.current.add(analyzeKey) + void handleAnalyzeJob(item.id, { mode: "replace" }) + } + const planKey = `${item.id}:plan:${item.frames.length}` + if (item.frames.length > 0 && !autoTriggeredRef.current.has(planKey)) { + autoTriggeredRef.current.add(planKey) + void handlePlanStoryboardJob(item.id) + } + } + }, [handleAnalyzeJob, handlePlanStoryboardJob, handleTranscribeAudio, jobs, productionJobIds]) + const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => { if (!job) return const frame = job.frames.find((f) => f.index === frameIdx) @@ -459,7 +581,7 @@ export default function Home() { label: `分镜 ${frameIdx + 1} 首帧`, } const orderedSelected = job.frames - .filter((f) => selectedFrames.has(f.index)) + .filter((f) => selectedFrames.size === 0 || selectedFrames.has(f.index)) .sort((a, b) => a.timestamp - b.timestamp) const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null const defaultLastRef: ImageRef | null = nextFrame @@ -467,7 +589,26 @@ export default function Home() { : null const firstRef = scene.first_image ?? keyframeRef const lastRef = scene.last_image ?? defaultLastRef - const productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) + let productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) + if (productRefs.length === 0) { + try { + productRefs = await ensureDefaultProductRefs(job.id) + } catch (e) { + toast.error("默认 SKG 产品图准备失败:" + (e instanceof Error ? e.message : String(e))) + return + } + } + const subjectRefs: ImageRef[] = (frame.elements ?? []) + .flatMap((element) => element.subject_assets ?? []) + .slice(0, 6) + .map((asset) => ({ + kind: "asset", + frame_idx: frameIdx, + element_id: asset.id, + cutout_id: asset.id, + label: asset.label, + })) + const primarySubjectRef = subjectRefs[0] ?? firstRef const duration = scene.duration && scene.duration > 0 ? scene.duration : 5 const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : "" const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : "" @@ -507,6 +648,7 @@ export default function Home() { `首帧:${labelOf(firstRef, "当前分镜关键帧")}`, `尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`, `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`, + subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。", sourceScene, sourceStyle, sourceObjects, @@ -528,7 +670,8 @@ export default function Home() { first_image: firstRef, last_image: lastRef, product_images: productRefs, - subject_image: firstRef, + subject_image: primarySubjectRef, + subject_images: subjectRefs, scene_image: null, product_image: productRefs[0] ?? null, action_image: null, @@ -542,7 +685,7 @@ export default function Home() { } catch (e) { toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e))) } - }, [job, selectedFrames, updateJobInList]) + }, [ensureDefaultProductRefs, job, selectedFrames, updateJobInList]) const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => { if (!job) return @@ -730,6 +873,7 @@ export default function Home() { videoPanelScale, videoPanelDock, onSubmitUrl: handleSubmit, + onStartProduction: handleStartProduction, onUploadFile: handleUpload, onAnalyze: handleAnalyze, onAnalyzeJob: handleAnalyzeJob, @@ -766,7 +910,7 @@ export default function Home() { onOpenAudioStrip: handleOpenAudioStrip, pinnedNodes, onToggleNodePin: handleToggleNodePin, - }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, handleTranscribeAudio, handleOpenAudioStrip, pinnedNodes, handleToggleNodePin]) + }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleStartProduction, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, handleTranscribeAudio, handleOpenAudioStrip, pinnedNodes, handleToggleNodePin]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const savedSizes = useMemo(() => loadNodeSizes(), []) diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 42c1424..b8cf8f3 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -9,14 +9,18 @@ import { toast } from "sonner" import { type FrameExtractQuality, type FrameExtractTarget, + type FrameObject, type GeneratedVideo, type Job, + type KeyElement, type KeyFrame, type StoryboardScene, + type SubjectKind, addElement, apiAssetUrl, cutoutElement, effectiveFrameUrl, + generateSubjectAssets, generatedImageUrl, hasCutout, representativeCutoutUrl, @@ -120,6 +124,34 @@ function countReadySegments(job: Job | null, drafts: DraftSegment[]) { return frameStoryboards + draftCount } +function guessSubjectKind(name: string): SubjectKind { + return /人|人物|模特|骨架|身体|脸|手|person|people|human|body|face|hand|character/i.test(name) + ? "living" + : "object" +} + +function buildFallbackScene(job: Job, frame: KeyFrame, order: number): StoryboardScene { + const frames = [...job.frames].sort((a, b) => a.timestamp - b.timestamp) + const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null + const duration = Math.max(3.5, Math.min(7.5, Math.max(job.duration || 0, frames.length * 5) / Math.max(frames.length, 1))) + const audio = job.audio_script?.rewritten_text?.trim() + || job.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ") + || "按原音频说话节奏改写为 SKG 产品介绍。" + const objects = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、") + return { + duration: Number(duration.toFixed(1)), + first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` }, + last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null, + subject: objects ? `关键元素候选:${objects}` : "保留原视频最重要的主体动作和构图关系。", + scene: `${frame.description?.scene || `参考第 ${order + 1} 个关键画面规划 SKG 信息流广告分镜。`}\n音频节奏依据:${audio.slice(0, 220)}`, + product: "把原素材里的产品/痛点转成 SKG 颈部/肩颈按摩仪表达,默认使用 SKG 四张产品角度图做产品真源。", + action: frame.description?.style + ? `沿用原画面的讲话节奏、动作节点和 ${frame.description.style},突出使用前紧绷、使用后放松。` + : "沿用原视频的讲话节奏和动作节点,突出使用前紧绷、使用后放松。", + reference_ids: [], + } +} + export function AdRecreationBoard({ data, onGenerateVideo, @@ -132,6 +164,8 @@ export function AdRecreationBoard({ const [selectedVideoIds, setSelectedVideoIds] = useState>(new Set()) const [draftSegments, setDraftSegments] = useState([]) const [elementBusyFrame, setElementBusyFrame] = useState(null) + const [sixViewBusyKey, setSixViewBusyKey] = useState(null) + const [generatingAll, setGeneratingAll] = useState(false) const fileRef = useRef(null) const selectedFrames = job ? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp) @@ -153,6 +187,12 @@ export function AdRecreationBoard({ setUrl("") } + const startProduction = () => { + const trimmed = url.trim() + data.onStartProduction?.(trimmed || undefined) + if (trimmed) setUrl("") + } + const selectAllFrames = () => { if (!job) return for (const frame of job.frames) { @@ -195,40 +235,103 @@ export function AdRecreationBoard({ }) } - const generateElementForFrame = async (frame: KeyFrame) => { + const generateElementForFrame = async (frame: KeyFrame, candidate?: FrameObject, withSixViews = true) => { if (!job) return setElementBusyFrame(frame.index) + const candidateName = candidate?.name?.trim() 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 + let workingJob = job + let workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? frame + const existing = workingFrame.elements?.find((item) => + candidateName + ? [item.name_zh, item.name_en].some((name) => name?.trim() === candidateName) + : true, + ) + const sourceObject = candidate ?? workingFrame.description?.objects?.[0] + const name = candidateName || sourceObject?.name?.trim() || existing?.name_zh || existing?.name_en || "主体" + let element = existing + + if (!element) { + workingJob = await addElement(job.id, frame.index, { + name_zh: name, + name_en: name, + position: sourceObject?.position, + source: "manual", + }) + data.onJobUpdate(workingJob) + workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame + element = workingFrame.elements?.[workingFrame.elements.length - 1] } - 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) + if (!element) { toast.success(`已登记元素:${name}`) return } - const updated = await cutoutElement(job.id, frame.index, newElement.id) - data.onJobUpdate(updated) - toast.success(`已生成元素:${name}`) + + if (!hasCutout(element)) { + workingJob = await cutoutElement(job.id, frame.index, element.id) + data.onJobUpdate(workingJob) + workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame + element = workingFrame.elements?.find((item) => item.id === element?.id) ?? element + } + + if (withSixViews && !element.subject_assets?.length) { + setSixViewBusyKey(`${frame.index}:${element.id}`) + workingJob = await generateSubjectAssets(job.id, frame.index, element.id, { + subject_kind: guessSubjectKind(name), + background: "white", + size: "1024", + source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index), + }) + data.onJobUpdate(workingJob) + } + + toast.success(`已准备关键元素:${name}`) } catch (e) { toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e))) } finally { setElementBusyFrame(null) + setSixViewBusyKey(null) + } + } + + const generateSixViewsForElement = async (frame: KeyFrame, element: KeyElement) => { + if (!job) return + setSixViewBusyKey(`${frame.index}:${element.id}`) + try { + const updated = await generateSubjectAssets(job.id, frame.index, element.id, { + subject_kind: guessSubjectKind(element.name_zh || element.name_en || "主体"), + background: "white", + size: "1024", + source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index), + }) + data.onJobUpdate(updated) + toast.success(`6 视图已生成:${element.name_zh || element.name_en}`) + } catch (e) { + toast.error("6 视图生成失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setSixViewBusyKey(null) + } + } + + const generateAllVideos = async () => { + if (!job || framesForSegments.length === 0) return + setGeneratingAll(true) + try { + for (let order = 0; order < framesForSegments.length; order += 1) { + const frame = framesForSegments[order] + const scene = frame.storyboard ?? buildFallbackScene(job, frame, order) + if (!frame.storyboard) { + const updated = await updateStoryboard(job.id, frame.index, scene) + data.onJobUpdate(updated) + } + await onGenerateVideo(frame.index, scene, "seedance") + } + toast.success(`已提交 ${framesForSegments.length} 条分镜视频`) + } catch (e) { + toast.error("批量生成失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setGeneratingAll(false) } } @@ -260,6 +363,7 @@ export function AdRecreationBoard({ setUrl={setUrl} fileRef={fileRef} onSubmitUrl={submitUrl} + onStartProduction={startProduction} />
@@ -285,6 +389,10 @@ export function AdRecreationBoard({ 追加分镜 + + {generatingAll ? : } + 生成全部视频 + @@ -321,9 +429,11 @@ export function AdRecreationBoard({ selectedVideoIds={selectedVideoIds} videos={generatedVideos.filter((video) => video.frame_idx === frame.index)} busy={elementBusyFrame === frame.index} + sixViewBusyKey={sixViewBusyKey} onToggleFrame={() => data.onToggleFrame(frame.index)} onJobUpdate={data.onJobUpdate} - onGenerateElement={() => generateElementForFrame(frame)} + onGenerateElement={(candidate) => generateElementForFrame(frame, candidate)} + onGenerateSixViews={(element) => generateSixViewsForElement(frame, element)} onGenerateVideo={onGenerateVideo} onToggleVideo={toggleVideo} onDeleteVideo={(videoId) => data.onDeleteVideo?.(videoId)} @@ -373,6 +483,7 @@ function MaterialColumn({ setUrl, fileRef, onSubmitUrl, + onStartProduction, }: { data: NodeData jobs: Job[] @@ -382,6 +493,7 @@ function MaterialColumn({ setUrl: (value: string) => void fileRef: RefObject onSubmitUrl: () => void + onStartProduction: () => void }) { return (
@@ -404,11 +516,11 @@ function MaterialColumn({ /> ))} - {!elements.length && !objectNames.length && 暂无识别元素} + {!objectCandidates.length && !elements.length && 暂无识别元素} + {elements.length > 0 && ( +
+ {elements.slice(0, 5).map((element) => { + const busySix = sixViewBusyKey === `${frame.index}:${element.id}` + return ( +
+ {element.name_zh || element.name_en} + +
+ ) + })} +
+ )} {(elementPreviews.length > 0 || generatedImages.length > 0) && (
{elementPreviews.slice(0, 6).map(({ element, src }) => ( @@ -676,9 +823,9 @@ function StoryboardSegmentCard({
)} - + onGenerateElement()}> {busy ? : } - 生成元素 + 提取+6视图 diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 479bf84..9245cea 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -41,7 +41,8 @@ export interface NodeData { videoPanelJobId?: string | null videoPanelScale?: number videoPanelDock?: CanvasPanelDock - onSubmitUrl: (url: string) => void + onSubmitUrl: (url: string) => Promise | Job | void + onStartProduction?: (url?: string) => Promise | void onUploadFile: (file: File) => void onAnalyze: (options?: { mode?: FrameExtractMode }) => void onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void