auto-save 2026-05-17 12:28 (~4)
This commit is contained in:
158
web/app/page.tsx
158
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<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(), [])
|
||||
|
||||
Reference in New Issue
Block a user