auto-save 2026-05-14 07:28 (~6)
This commit is contained in:
@@ -18,8 +18,8 @@ import {
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import {
|
||||
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
||||
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo,
|
||||
type Job, type ImageRef, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
|
||||
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, createProductFusionGuide,
|
||||
type Job, type ImageRef, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
|
||||
} from "@/lib/api"
|
||||
|
||||
const NODE_TYPES = {
|
||||
@@ -443,6 +443,60 @@ export default function Home() {
|
||||
}
|
||||
}, [job, selectedFrames, setJob])
|
||||
|
||||
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
|
||||
if (!job) return
|
||||
const frame = job.frames.find((f) => f.index === frameIdx)
|
||||
if (!frame) return
|
||||
if (!shot.product_image || !shot.person_image || !shot.scene_image || !shot.product_region || !shot.action_text?.trim()) {
|
||||
toast.error("产品融合镜头缺少产品图、人物图、区域、场景图或描述词")
|
||||
return
|
||||
}
|
||||
const duration = shot.duration && shot.duration > 0 ? shot.duration : 5
|
||||
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
|
||||
try {
|
||||
toast.info(`生成融合引导图 · GPT Image 2 位置约束 · 镜头 ${shot.id || ""}`)
|
||||
const guideRef = await createProductFusionGuide(job.id, {
|
||||
...shot,
|
||||
image_model: "gpt-image-2",
|
||||
video_model: "seedance",
|
||||
})
|
||||
const region = shot.product_region
|
||||
const prompt = [
|
||||
`竖屏 9:16,${duration.toFixed(1)} 秒,Seedance 产品融合视频。`,
|
||||
"图片模型固定为 GPT Image 2:已根据白底人物图和手动画框生成产品融合引导图;引导图是产品尺寸、位置、贴合关系和起始构图的最高优先级参考。",
|
||||
"视频模型固定为 Seedance:生成单镜头连续视频,不跳切,不换主体,不改变产品身份。",
|
||||
`产品区域:x=${region.x.toFixed(3)}, y=${region.y.toFixed(3)}, w=${region.w.toFixed(3)}, h=${region.h.toFixed(3)}。产品只能在这个框对应的人物/身体/手部区域内融合,不能漂移到其他位置,也不能明显超出框架。`,
|
||||
`产品图:${labelOf(shot.product_image, "SKG 白底产品图")}。严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例。`,
|
||||
`白底人物图:${labelOf(shot.person_image, "人物姿态参考")}。人物姿态、手部接触点和产品佩戴关系以这张图为准。`,
|
||||
`场景图:${labelOf(shot.scene_image, "场景参考")}。背景、空间、光线和气氛以这张图为准,但不要改变产品框内位置。`,
|
||||
`动作描述:${shot.action_text.trim()}`,
|
||||
"融合要求:产品必须按引导图位置自然贴合人物或手部,尺寸可信,透视一致,边缘清晰,不能悬浮、穿帮、融化、扭曲或变成其他物体。",
|
||||
"场景要求:把白底人物姿态自然放入场景图的环境中,光线方向和阴影要统一,背景不要出现水印、平台 UI、字幕或竞品包装。",
|
||||
"商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。",
|
||||
"禁止:文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、产品超过指定融合区域。",
|
||||
].join("\n")
|
||||
const updated = await generateStoryboardVideo(job.id, frameIdx, {
|
||||
prompt,
|
||||
duration,
|
||||
first_image: guideRef,
|
||||
last_image: null,
|
||||
product_images: [shot.product_image, shot.person_image, shot.scene_image].filter(Boolean) as ImageRef[],
|
||||
subject_image: shot.person_image,
|
||||
scene_image: shot.scene_image,
|
||||
product_image: shot.product_image,
|
||||
action_image: guideRef,
|
||||
source_ref: null,
|
||||
model: "seedance",
|
||||
size: "720x1280",
|
||||
})
|
||||
setJob(updated)
|
||||
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
||||
toast.success("产品融合视频已进入 Video Gen 队列")
|
||||
} catch (e) {
|
||||
toast.error("产品融合生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}, [job, setJob])
|
||||
|
||||
// 启动恢复:URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -597,9 +651,10 @@ export default function Home() {
|
||||
setWorkbenchOpen(true)
|
||||
},
|
||||
onCopyImage: handleCopyImage,
|
||||
onGenerateProductFusionVideo: handleGenerateProductFusionVideo,
|
||||
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, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, 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, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, handleGenerateProductFusionVideo, pinnedNodes, handleToggleNodePin])
|
||||
|
||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
||||
|
||||
Reference in New Issue
Block a user