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

@@ -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<FrameExtractQuality, string> = {
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<CanvasPanelDock>("left")
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
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 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(), [])