auto-save 2026-05-17 12:28 (~4)

This commit is contained in:
2026-05-17 12:28:26 +08:00
parent 652a487af8
commit 08f18373b9
4 changed files with 358 additions and 67 deletions

View File

@@ -1,31 +1,5 @@
{ {
"entries": [ "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, "files_changed": 1,
"hash": "8b1e0bd", "hash": "8b1e0bd",
@@ -3269,6 +3243,31 @@
"message": "auto-save 2026-05-17 12:01 (+1, ~1)", "message": "auto-save 2026-05-17 12:01 (+1, ~1)",
"hash": "7d399b8", "hash": "7d399b8",
"files_changed": 2 "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
} }
] ]
} }

View File

@@ -19,8 +19,8 @@ import { AudioStrip } from "@/components/audio-strip"
import { AdRecreationBoard } from "@/components/ad-recreation-board" import { AdRecreationBoard } from "@/components/ad-recreation-board"
import { import {
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset,
type Job, type ImageRef, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
} from "@/lib/api" } from "@/lib/api"
import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target" import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target"
@@ -53,6 +53,12 @@ const FRAME_QUALITY_LABELS: Record<FrameExtractQuality, string> = {
accurate: "精细", accurate: "精细",
ultra: "极准", 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 = [ const PRODUCT_FUSION_WEARING_PROMPT = [
"Product placement must be physically correct:", "Product placement must be physically correct:",
@@ -150,6 +156,10 @@ export default function Home() {
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left") const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left")
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0) const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
const [clipboard, setClipboard] = useState<ImageRef | null>(null) const [clipboard, setClipboard] = useState<ImageRef | null>(null)
const [productionJobIds, setProductionJobIds] = useState<Set<string>>(new Set())
const [planningJobIds, setPlanningJobIds] = useState<Set<string>>(new Set())
const [defaultProductRefsByJob, setDefaultProductRefsByJob] = useState<Record<string, ImageRef[]>>({})
const autoTriggeredRef = useRef<Set<string>>(new Set())
const flowRef = useRef<any>(null) const flowRef = useRef<any>(null)
const lastVideoPanelFocusKey = useRef("") const lastVideoPanelFocusKey = useRef("")
@@ -201,8 +211,10 @@ export default function Home() {
const created = await createJob(url) const created = await createJob(url)
addJob(created) addJob(created)
toast.success(`已创建任务 ${created.id.slice(0, 8)}`) toast.success(`已创建任务 ${created.id.slice(0, 8)}`)
return created
} catch (e) { } catch (e) {
toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) toast.error("提交失败:" + (e instanceof Error ? e.message : String(e)))
return undefined
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -447,6 +459,116 @@ export default function Home() {
} }
}, [activeJobId, jobs, updateJobInList]) }, [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) => { const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
if (!job) return if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx) const frame = job.frames.find((f) => f.index === frameIdx)
@@ -459,7 +581,7 @@ export default function Home() {
label: `分镜 ${frameIdx + 1} 首帧`, label: `分镜 ${frameIdx + 1} 首帧`,
} }
const orderedSelected = job.frames 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) .sort((a, b) => a.timestamp - b.timestamp)
const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null
const defaultLastRef: ImageRef | null = nextFrame const defaultLastRef: ImageRef | null = nextFrame
@@ -467,7 +589,26 @@ export default function Home() {
: null : null
const firstRef = scene.first_image ?? keyframeRef const firstRef = scene.first_image ?? keyframeRef
const lastRef = scene.last_image ?? defaultLastRef 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 duration = scene.duration && scene.duration > 0 ? scene.duration : 5
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : "" const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : "" const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
@@ -507,6 +648,7 @@ export default function Home() {
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`, `首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`, `尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
`SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join("") : "SKG 产品视觉主角"}`, `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, sourceScene,
sourceStyle, sourceStyle,
sourceObjects, sourceObjects,
@@ -528,7 +670,8 @@ export default function Home() {
first_image: firstRef, first_image: firstRef,
last_image: lastRef, last_image: lastRef,
product_images: productRefs, product_images: productRefs,
subject_image: firstRef, subject_image: primarySubjectRef,
subject_images: subjectRefs,
scene_image: null, scene_image: null,
product_image: productRefs[0] ?? null, product_image: productRefs[0] ?? null,
action_image: null, action_image: null,
@@ -542,7 +685,7 @@ export default function Home() {
} catch (e) { } catch (e) {
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(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) => { const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
if (!job) return if (!job) return
@@ -730,6 +873,7 @@ export default function Home() {
videoPanelScale, videoPanelScale,
videoPanelDock, videoPanelDock,
onSubmitUrl: handleSubmit, onSubmitUrl: handleSubmit,
onStartProduction: handleStartProduction,
onUploadFile: handleUpload, onUploadFile: handleUpload,
onAnalyze: handleAnalyze, onAnalyze: handleAnalyze,
onAnalyzeJob: handleAnalyzeJob, onAnalyzeJob: handleAnalyzeJob,
@@ -766,7 +910,7 @@ export default function Home() {
onOpenAudioStrip: handleOpenAudioStrip, onOpenAudioStrip: handleOpenAudioStrip,
pinnedNodes, pinnedNodes,
onToggleNodePin: handleToggleNodePin, 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 // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const savedSizes = useMemo(() => loadNodeSizes(), []) const savedSizes = useMemo(() => loadNodeSizes(), [])

View File

@@ -9,14 +9,18 @@ import { toast } from "sonner"
import { import {
type FrameExtractQuality, type FrameExtractQuality,
type FrameExtractTarget, type FrameExtractTarget,
type FrameObject,
type GeneratedVideo, type GeneratedVideo,
type Job, type Job,
type KeyElement,
type KeyFrame, type KeyFrame,
type StoryboardScene, type StoryboardScene,
type SubjectKind,
addElement, addElement,
apiAssetUrl, apiAssetUrl,
cutoutElement, cutoutElement,
effectiveFrameUrl, effectiveFrameUrl,
generateSubjectAssets,
generatedImageUrl, generatedImageUrl,
hasCutout, hasCutout,
representativeCutoutUrl, representativeCutoutUrl,
@@ -120,6 +124,34 @@ function countReadySegments(job: Job | null, drafts: DraftSegment[]) {
return frameStoryboards + draftCount 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({ export function AdRecreationBoard({
data, data,
onGenerateVideo, onGenerateVideo,
@@ -132,6 +164,8 @@ export function AdRecreationBoard({
const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set()) const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set())
const [draftSegments, setDraftSegments] = useState<DraftSegment[]>([]) const [draftSegments, setDraftSegments] = useState<DraftSegment[]>([])
const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null) const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
const [sixViewBusyKey, setSixViewBusyKey] = useState<string | null>(null)
const [generatingAll, setGeneratingAll] = useState(false)
const fileRef = useRef<HTMLInputElement | null>(null) const fileRef = useRef<HTMLInputElement | null>(null)
const selectedFrames = job const selectedFrames = job
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp) ? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
@@ -153,6 +187,12 @@ export function AdRecreationBoard({
setUrl("") setUrl("")
} }
const startProduction = () => {
const trimmed = url.trim()
data.onStartProduction?.(trimmed || undefined)
if (trimmed) setUrl("")
}
const selectAllFrames = () => { const selectAllFrames = () => {
if (!job) return if (!job) return
for (const frame of job.frames) { 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 if (!job) return
setElementBusyFrame(frame.index) setElementBusyFrame(frame.index)
const candidateName = candidate?.name?.trim()
try { try {
const existing = frame.elements?.[0] let workingJob = job
if (existing) { let workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? frame
const updated = await cutoutElement(job.id, frame.index, existing.id) const existing = workingFrame.elements?.find((item) =>
data.onJobUpdate(updated) candidateName
toast.success(`已生成元素:${existing.name_zh || existing.name_en || "主体"}`) ? [item.name_zh, item.name_en].some((name) => name?.trim() === candidateName)
return : 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] if (!element) {
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}`) toast.success(`已登记元素:${name}`)
return return
} }
const updated = await cutoutElement(job.id, frame.index, newElement.id)
data.onJobUpdate(updated) if (!hasCutout(element)) {
toast.success(`已生成元素:${name}`) 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) { } catch (e) {
toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e))) toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally { } finally {
setElementBusyFrame(null) 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} setUrl={setUrl}
fileRef={fileRef} fileRef={fileRef}
onSubmitUrl={submitUrl} onSubmitUrl={submitUrl}
onStartProduction={startProduction}
/> />
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl"> <section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl">
@@ -285,6 +389,10 @@ export function AdRecreationBoard({
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</ActionButton> </ActionButton>
<ActionButton disabled={!framesForSegments.length || generatingAll} onClick={generateAllVideos}>
{generatingAll ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</ActionButton>
</div> </div>
</div> </div>
@@ -321,9 +429,11 @@ export function AdRecreationBoard({
selectedVideoIds={selectedVideoIds} selectedVideoIds={selectedVideoIds}
videos={generatedVideos.filter((video) => video.frame_idx === frame.index)} videos={generatedVideos.filter((video) => video.frame_idx === frame.index)}
busy={elementBusyFrame === frame.index} busy={elementBusyFrame === frame.index}
sixViewBusyKey={sixViewBusyKey}
onToggleFrame={() => data.onToggleFrame(frame.index)} onToggleFrame={() => data.onToggleFrame(frame.index)}
onJobUpdate={data.onJobUpdate} onJobUpdate={data.onJobUpdate}
onGenerateElement={() => generateElementForFrame(frame)} onGenerateElement={(candidate) => generateElementForFrame(frame, candidate)}
onGenerateSixViews={(element) => generateSixViewsForElement(frame, element)}
onGenerateVideo={onGenerateVideo} onGenerateVideo={onGenerateVideo}
onToggleVideo={toggleVideo} onToggleVideo={toggleVideo}
onDeleteVideo={(videoId) => data.onDeleteVideo?.(videoId)} onDeleteVideo={(videoId) => data.onDeleteVideo?.(videoId)}
@@ -373,6 +483,7 @@ function MaterialColumn({
setUrl, setUrl,
fileRef, fileRef,
onSubmitUrl, onSubmitUrl,
onStartProduction,
}: { }: {
data: NodeData data: NodeData
jobs: Job[] jobs: Job[]
@@ -382,6 +493,7 @@ function MaterialColumn({
setUrl: (value: string) => void setUrl: (value: string) => void
fileRef: RefObject<HTMLInputElement | null> fileRef: RefObject<HTMLInputElement | null>
onSubmitUrl: () => void onSubmitUrl: () => void
onStartProduction: () => void
}) { }) {
return ( 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"> <section className="flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
@@ -404,11 +516,11 @@ function MaterialColumn({
/> />
<button <button
type="button" type="button"
onClick={onSubmitUrl} onClick={onStartProduction}
disabled={data.submitting || !url.trim()} 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" 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>
<button <button
type="button" type="button"
@@ -525,9 +637,11 @@ function StoryboardSegmentCard({
selectedVideoIds, selectedVideoIds,
videos, videos,
busy, busy,
sixViewBusyKey,
onToggleFrame, onToggleFrame,
onJobUpdate, onJobUpdate,
onGenerateElement, onGenerateElement,
onGenerateSixViews,
onGenerateVideo, onGenerateVideo,
onToggleVideo, onToggleVideo,
onDeleteVideo, onDeleteVideo,
@@ -539,9 +653,11 @@ function StoryboardSegmentCard({
selectedVideoIds: Set<string> selectedVideoIds: Set<string>
videos: GeneratedVideo[] videos: GeneratedVideo[]
busy: boolean busy: boolean
sixViewBusyKey: string | null
onToggleFrame: () => void onToggleFrame: () => void
onJobUpdate: (job: Job) => void onJobUpdate: (job: Job) => void
onGenerateElement: () => void onGenerateElement: (candidate?: FrameObject) => void
onGenerateSixViews: (element: KeyElement) => void
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
onToggleVideo: (videoId: string) => void onToggleVideo: (videoId: string) => void
onDeleteVideo?: (videoId: string) => void onDeleteVideo?: (videoId: string) => void
@@ -552,7 +668,8 @@ function StoryboardSegmentCard({
const [generatingVideo, setGeneratingVideo] = useState(false) const [generatingVideo, setGeneratingVideo] = useState(false)
const elements = frame.elements ?? [] const elements = frame.elements ?? []
const generatedImages = frame.generated_images ?? [] const generatedImages = frame.generated_images ?? []
const objectNames = frame.description?.objects?.slice(0, 4).map((item) => item.name).filter(Boolean) ?? [] const objectCandidates = frame.description?.objects?.slice(0, 8).filter((item) => item.name?.trim()) ?? []
const objectNames = objectCandidates.map((item) => item.name)
const elementPreviews = elements const elementPreviews = elements
.map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) })) .map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) }))
.filter((item): item is { element: typeof elements[number]; src: string } => !!item.src) .filter((item): item is { element: typeof elements[number]; src: string } => !!item.src)
@@ -660,11 +777,41 @@ function StoryboardSegmentCard({
<span>{generatedImages.length} </span> <span>{generatedImages.length} </span>
</div> </div>
<div className="mb-2 flex flex-wrap gap-1"> <div className="mb-2 flex flex-wrap gap-1">
{(elements.length ? elements.map((item) => item.name_zh || item.name_en) : objectNames).slice(0, 8).map((name) => ( {objectCandidates.length > 0 && objectCandidates.map((candidate) => (
<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> <button
key={`${candidate.name}:${candidate.position ?? ""}`}
type="button"
onClick={() => onGenerateElement(candidate)}
disabled={busy}
className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
title="选择该元素并生成提取图 + 6 视图"
>
{candidate.name}
</button>
))} ))}
{!elements.length && !objectNames.length && <span className="text-[11px] text-white/32"></span>} {!objectCandidates.length && !elements.length && <span className="text-[11px] text-white/32"></span>}
</div> </div>
{elements.length > 0 && (
<div className="mb-2 grid gap-1">
{elements.slice(0, 5).map((element) => {
const busySix = sixViewBusyKey === `${frame.index}:${element.id}`
return (
<div key={element.id} className="flex items-center justify-between gap-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5">
<span className="min-w-0 truncate text-[11px] text-white/62">{element.name_zh || element.name_en}</span>
<button
type="button"
onClick={() => onGenerateSixViews(element)}
disabled={busySix}
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/10 px-2 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
>
{busySix ? <Loader2 className="h-3 w-3 animate-spin" /> : <ImageIcon className="h-3 w-3" />}
{element.subject_assets?.length ? `${element.subject_assets.length}视图` : "6视图"}
</button>
</div>
)
})}
</div>
)}
{(elementPreviews.length > 0 || generatedImages.length > 0) && ( {(elementPreviews.length > 0 || generatedImages.length > 0) && (
<div className="flex gap-1 overflow-x-auto pb-1"> <div className="flex gap-1 overflow-x-auto pb-1">
{elementPreviews.slice(0, 6).map(({ element, src }) => ( {elementPreviews.slice(0, 6).map(({ element, src }) => (
@@ -676,9 +823,9 @@ function StoryboardSegmentCard({
</div> </div>
)} )}
</div> </div>
<ActionButton disabled={busy} variant="ghost" onClick={onGenerateElement}> <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" />} {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
+6
</ActionButton> </ActionButton>
</div> </div>
</SegmentBand> </SegmentBand>

View File

@@ -41,7 +41,8 @@ export interface NodeData {
videoPanelJobId?: string | null videoPanelJobId?: string | null
videoPanelScale?: number videoPanelScale?: number
videoPanelDock?: CanvasPanelDock videoPanelDock?: CanvasPanelDock
onSubmitUrl: (url: string) => void onSubmitUrl: (url: string) => Promise<Job | void> | Job | void
onStartProduction?: (url?: string) => Promise<void> | void
onUploadFile: (file: File) => void onUploadFile: (file: File) => void
onAnalyze: (options?: { mode?: FrameExtractMode }) => void onAnalyze: (options?: { mode?: FrameExtractMode }) => void
onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void