Files
20260512-skg-tk/web/app/page.tsx
2026-05-14 13:10:42 +08:00

1033 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTheme } from "next-themes"
import {
ReactFlow, Background, BackgroundVariant, Controls, MiniMap,
useNodesState, useEdgesState,
type Node, type Edge,
} from "@xyflow/react"
import { Toaster, toast } from "sonner"
import { LayoutGrid } from "lucide-react"
import {
InputNode, VisualLabNode, AudioNode,
ComposeNode, KeyframePanelNode,
VideoFramePanelNode,
type CanvasPanelDock,
type NodeData,
} from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle"
import { AudioStrip } from "@/components/audio-strip"
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,
} from "@/lib/api"
import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target"
const NODE_TYPES = {
input: InputNode,
visual: VisualLabNode,
audio: AudioNode,
compose: ComposeNode,
keyframePanel: KeyframePanelNode,
videoFramePanel: VideoFramePanelNode,
}
const KEYFRAME_PANEL_ID = "keyframe-detail-panel"
const VIDEO_FRAME_PANEL_ID = "video-frame-panel"
const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID])
const FRAME_TARGET_LABELS: Record<FrameExtractTarget, string> = {
transparent_human: "透明骨架人",
balanced: "综合关键帧",
subject: "清晰主体",
transition: "转场变化",
expression: "表情瞬间",
motion: "动作峰值",
}
const FRAME_QUALITY_LABELS: Record<FrameExtractQuality, string> = {
auto: "自动",
fast: "快速",
accurate: "精细",
ultra: "极准",
}
// 合并 input + download + split 为一个节点
// 分叉:上路 input → visual lab ↘
// 下路 input → audio ──────────────────────────→ compose
const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number; w: number }> = [
{ id: "input", type: "input", x: 40, y: 240, w: 320 },
{ id: "visual", type: "visual", x: 460, y: 60, w: 620 },
{ id: "audio", type: "audio", x: 460, y: 440, w: 320 },
{ id: "compose", type: "compose", x: 1160, y: 240, w: 320 },
]
const NODE_SIZES_KEY = "skg-tk:node-sizes:v2"
type NodeSize = { w?: number; h?: number }
function loadNodeSizes(): Record<string, NodeSize> {
if (typeof window === "undefined") return {}
try {
const raw = window.localStorage.getItem(NODE_SIZES_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
const NODE_PINS_KEY = "skg-tk:node-pins:v1"
function loadNodePins(): string[] {
if (typeof window === "undefined") return []
try {
const raw = window.localStorage.getItem(NODE_PINS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
const EDGES_RAW: Array<[string, string]> = [
["input", "visual"],
["input", "audio"],
["visual", "compose"],
["audio", "compose"],
]
export default function Home() {
const { resolvedTheme } = useTheme()
const [clientReady, setClientReady] = useState(false)
const [jobs, setJobs] = useState<Job[]>([])
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
const [audioStripJobId, setAudioStripJobId] = useState<string | null>(null)
const audioStripJob = useMemo(() => jobs.find((j) => j.id === audioStripJobId) ?? null, [jobs, audioStripJobId])
const [submitting, setSubmitting] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const [frameTargets, setFrameTargets] = useState<Record<string, FrameExtractTarget>>({})
const [frameCounts, setFrameCounts] = useState<Record<string, number>>({})
const [frameQualities, setFrameQualities] = useState<Record<string, FrameExtractQuality>>({})
const [selectedFramesByJob, setSelectedFramesByJob] = useState<Record<string, number[]>>({})
const [expandedFrameByJob, setExpandedFrameByJob] = useState<Record<string, number | null>>({})
const selectedFrames = useMemo(
() => new Set(activeJobId ? selectedFramesByJob[activeJobId] ?? [] : []),
[activeJobId, selectedFramesByJob],
)
const expandedFrame = activeJobId ? expandedFrameByJob[activeJobId] ?? null : null
const [framePanelScale, setFramePanelScale] = useState(1)
const [framePanelDock, setFramePanelDock] = useState<CanvasPanelDock>("left")
const framePanelPinned = framePanelDock !== "canvas"
const [videoPanelJobId, setVideoPanelJobId] = useState<string | null>(null)
const [videoPanelScale, setVideoPanelScale] = useState(1)
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left")
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
const flowRef = useRef<any>(null)
const lastVideoPanelFocusKey = useRef("")
useEffect(() => {
setClientReady(true)
}, [])
const setSelectedFramesForJob = useCallback((jobId: string, updater: Set<number> | ((prev: Set<number>) => Set<number>)) => {
setSelectedFramesByJob((prev) => {
const current = new Set(prev[jobId] ?? [])
const next = typeof updater === "function" ? updater(current) : updater
return { ...prev, [jobId]: [...next].sort((a, b) => a - b) }
})
}, [])
const clearWorkflowStateForJob = useCallback((jobId: string) => {
setSelectedFramesByJob((prev) => ({ ...prev, [jobId]: [] }))
setExpandedFrameByJob((prev) => ({ ...prev, [jobId]: null }))
}, [])
const updateJobInList = useCallback((updated: Job) => {
setJobs((prev) => {
const idx = prev.findIndex((j) => j.id === updated.id)
if (idx < 0) return [...prev, updated]
const arr = [...prev]
arr[idx] = updated
return arr
})
}, [])
// 新增 job + 设为 active
const addJob = useCallback((j: Job) => {
setJobs((prev) => [...prev.filter((x) => x.id !== j.id), j])
setActiveJobId(j.id)
}, [])
const handleSwitchJob = useCallback((id: string) => {
setActiveJobId(id)
}, [])
const handleOpenAudioStrip = useCallback((jobId?: string) => {
const targetId = jobId ?? activeJobId
if (targetId) setAudioStripJobId(targetId)
}, [activeJobId])
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const handleSubmit = useCallback(async (url: string) => {
setSubmitting(true)
try {
const created = await createJob(url)
addJob(created)
toast.success(`已创建任务 ${created.id.slice(0, 8)}`)
} catch (e) {
toast.error("提交失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubmitting(false)
}
}, [addJob])
const handleUpload = useCallback(async (file: File) => {
setSubmitting(true)
try {
toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
const created = await uploadJob(file)
addJob(created)
toast.success(`已上传 ${created.id.slice(0, 8)}`)
} catch (e) {
toast.error("上传失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubmitting(false)
}
}, [addJob])
const handleAnalyzeJob = useCallback(async (jobId: string, options?: { mode?: FrameExtractMode }) => {
const targetJob = jobs.find((item) => item.id === jobId)
if (!targetJob) return
const frameTarget = frameTargets[jobId] ?? "transparent_human"
const frameCount = frameCounts[jobId] ?? 12
const frameQuality = frameQualities[jobId] ?? "auto"
const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
setActiveJobId(jobId)
setAnalyzing(true)
if (mode === "replace") clearWorkflowStateForJob(jobId)
try {
await analyzeJob(jobId, frameCount, frameTarget, mode, frameQuality)
toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount}`)
setJobs((prev) => prev.map((item) => item.id === jobId ? {
...item,
status: "splitting",
message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]}`,
progress: 30,
} : item))
} catch (e) {
toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setAnalyzing(false)
}
}, [jobs, frameCounts, frameQualities, frameTargets, clearWorkflowStateForJob])
const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
if (!job) return
await handleAnalyzeJob(job.id, options)
}, [job?.id, handleAnalyzeJob])
const handleFrameTargetChange = useCallback((jobId: string, target: FrameExtractTarget) => {
setFrameTargets((prev) => ({ ...prev, [jobId]: target }))
}, [])
const handleFrameCountChange = useCallback((jobId: string, count: number) => {
setFrameCounts((prev) => ({ ...prev, [jobId]: Math.max(1, Math.min(20, count)) }))
}, [])
const handleFrameQualityChange = useCallback((jobId: string, quality: FrameExtractQuality) => {
setFrameQualities((prev) => ({ ...prev, [jobId]: quality }))
}, [])
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
try {
const updated = await addManualFrame(jobId, t)
updateJobInList(updated)
setActiveJobId((prev) => prev ?? updated.id)
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length}`)
} catch (e) {
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [updateJobInList])
const handleAddManualFrame = useCallback(async (t: number) => {
if (!job) return
await handleAddManualFrameForJob(job.id, t)
}, [job?.id, handleAddManualFrameForJob])
const handleOpenVideoPanel = useCallback((jobId: string) => {
setActiveJobId(jobId)
if (!videoPanelJobId) setVideoPanelDock("left")
setVideoPanelJobId(jobId)
setVideoPanelOpenTick((n) => n + 1)
}, [videoPanelJobId])
const handleVideoPanelScaleChange = useCallback((scale: number) => {
setVideoPanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
}, [])
const handleToggleFrame = useCallback((idx: number) => {
if (!activeJobId) return
setSelectedFramesForJob(activeJobId, (prev) => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx)
else next.add(idx)
return next
})
}, [activeJobId, setSelectedFramesForJob])
const handleOpenFramePanel = useCallback((idx: number) => {
if (!activeJobId) return
if (expandedFrame === null) setFramePanelDock("left")
setExpandedFrameByJob((prev) => ({ ...prev, [activeJobId]: idx }))
}, [activeJobId, expandedFrame])
const handleCloseExpandedFrame = useCallback(() => {
if (!activeJobId) return
setExpandedFrameByJob((prev) => ({ ...prev, [activeJobId]: null }))
}, [activeJobId])
const handleOpenStoryboard = useCallback((idx: number) => {
if (!activeJobId) return
setSelectedFramesForJob(activeJobId, (prev) => {
const next = new Set(prev)
next.add(idx)
return next
})
}, [activeJobId, setSelectedFramesForJob])
const handleOpenWorkbench = useCallback((idx?: number) => {
if (!activeJobId || typeof idx !== "number") return
setSelectedFramesForJob(activeJobId, (prev) => {
const next = new Set(prev)
next.add(idx)
return next
})
}, [activeJobId, setSelectedFramesForJob])
const handleFramePanelScaleChange = useCallback((scale: number) => {
setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
}, [])
const handleDeleteFrameForJob = useCallback(async (jobId: string, idx: number) => {
const wasActive = activeJobId === jobId
try {
const updated = await deleteFrame(jobId, idx)
updateJobInList(updated)
setSelectedFramesForJob(jobId, (prev) => {
if (!prev.has(idx)) return prev
const next = new Set(prev)
next.delete(idx)
return next
})
setExpandedFrameByJob((prev) => prev[jobId] === idx ? { ...prev, [jobId]: null } : prev)
if (wasActive) setActiveJobId(updated.id)
toast.success(`分镜 ${idx + 1} 已删除`)
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, setSelectedFramesForJob, updateJobInList])
const handleDeleteFrame = useCallback(async (idx: number) => {
if (!activeJobId) return
await handleDeleteFrameForJob(activeJobId, idx)
}, [activeJobId, handleDeleteFrameForJob])
const handleDeleteGenerated = useCallback(async (frameIdx: number, genId: string) => {
if (!activeJobId) return
try {
const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId)
updateJobInList(updated)
toast.success("生成图已删除")
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, updateJobInList])
const handleDeleteVideo = useCallback(async (videoId: string) => {
if (!activeJobId) return
try {
const updated = await deleteGeneratedVideo(activeJobId, videoId)
updateJobInList(updated)
toast.success("视频任务已删除")
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, updateJobInList])
const handleDeleteJob = useCallback(async (jobId: string) => {
try {
await deleteJob(jobId)
setVideoPanelJobId((prev) => prev === jobId ? null : prev)
setJobs((prev) => {
const idx = prev.findIndex((x) => x.id === jobId)
const next = prev.filter((x) => x.id !== jobId)
if (activeJobId === jobId) {
const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null
setActiveJobId(fallback?.id ?? null)
}
return next
})
setSelectedFramesByJob((prev) => {
const { [jobId]: _removed, ...rest } = prev
return rest
})
setExpandedFrameByJob((prev) => {
const { [jobId]: _removed, ...rest } = prev
return rest
})
toast.success("输入视频已删除")
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId])
const handleDeleteCutout = useCallback(async (frameIdx: number, elementId: string, cutoutId: string) => {
if (!activeJobId) return
try {
const updated = await deleteCutout(activeJobId, frameIdx, elementId, cutoutId)
updateJobInList(updated)
toast.success("元素提取图已删除")
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, updateJobInList])
const handleCopyImage = useCallback((ref: ImageRef) => {
setClipboard(ref)
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
}, [])
const handleTranscribeAudio = useCallback(async (jobId?: string, options?: { silent?: boolean }) => {
const targetId = jobId ?? activeJobId
if (!targetId) return
setAudioStripJobId(targetId)
const target = jobs.find((item) => item.id === targetId)
if (!target) return
if (!target.video_url) {
if (!options?.silent) toast.info("视频导入完成后,可在音频卡片点击提取音频")
return
}
if (target.status === "transcribing" || target.audio_script?.status === "rewriting") {
if (!options?.silent) toast.info("音频正在处理中")
return
}
try {
const updated = await triggerTranscribe(targetId)
updateJobInList(updated)
if (!options?.silent) toast.success("已开始提取音频")
} catch (e) {
if (!options?.silent) toast.error("音频处理启动失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, jobs, updateJobInList])
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
const keyframeRef: ImageRef = {
kind: "keyframe",
frame_idx: frameIdx,
label: `分镜 ${frameIdx + 1} 首帧`,
}
const orderedSelected = job.frames
.filter((f) => 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
? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${nextFrame.index + 1} 尾帧` }
: 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] : [])
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}` : ""
const sourceObjects = frame.description?.objects?.length
? `参考元素:${frame.description.objects.slice(0, 6).map((o) => o.name).join("、")}`
: ""
const subjectDirection = scene.subject?.trim()
|| "保留首尾帧里的主体位置、手部动作关系和镜头调度;如果参考主体是人形骷髅、透明骨骼人或卡通骨骼角色,可以保留为视觉隐喻,让它正确佩戴 SKG 颈部按摩仪后状态变好。不要做恐怖、血腥或严肃医疗治疗画面。"
const productDirection = scene.product?.trim()
|| "以已上传 SKG 产品图为唯一产品真源,把参考视频或首尾帧里的任何非 SKG 产品替换成该产品;产品轮廓、颜色、材质、按键/接口/包装比例必须稳定,不要变成其他物体。"
const sceneDirection = scene.scene?.trim()
|| "借鉴参考画面的构图、可信感和空间层次,但改造成适合 SKG 产品广告的现代家居、办公或零售场景。"
const actionDirection = scene.action?.trim()
|| "按首帧到尾帧做平滑过渡,动作连续自然,镜头运动稳定,最后准确停在尾帧意图。"
const productNature = [
"产品性质:这是 SKG 白色 U 形可穿戴颈部/肩颈按摩仪,不是药品、护肤品、饮料、瓶罐、医疗器械镜头道具或普通项链。",
"正确使用方式:产品应佩戴/环绕在人的脖子和肩颈位置U 形机身落在肩颈两侧,内侧金属按摩触点贴合后颈或肩颈肌肉区域。",
"可表现的交互:手拿起产品、展开并戴到脖子上、轻按侧边圆形按键/控制区、轻微调整贴合位置、闭眼放松、肩颈从紧绷变舒展。",
"效果表达:使用后状态变好,表现为颈肩放松、姿态更自然、表情舒缓、精神恢复;如果主体是人形骷髅,可以通过放下揉脖子的手、抬头、肩颈舒展、表情/动作变轻松来表现改善。不要表现治愈疾病、骨骼修复、皮肤祛痘或夸张医疗效果。",
].join("\n")
const prompt = [
`竖屏 9:16${duration.toFixed(1)}SKG 产品短视频广告。`,
productNature,
productRefs.length
? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。`
: "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。",
"首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。",
"使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。",
"生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
"如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
"时间线0%-15% 锁住首帧构图并轻微启动15%-85% 做平滑连续运动85%-100% 缓慢贴近尾帧并稳定收住。",
TRANSPARENT_HUMAN_VIDEO_PROMPT,
`主体改造:${subjectDirection}`,
`产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`,
`场景改造:${sceneDirection}`,
`连续动作和镜头:${actionDirection}`,
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
`SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join("") : "SKG 产品视觉主角"}`,
sourceScene,
sourceStyle,
sourceObjects,
"产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。",
"产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。",
"状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。",
"运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。",
"商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。",
"禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。",
TRANSPARENT_HUMAN_NEGATIVE_PROMPT,
].join("\n")
try {
toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
const sourceUrl = job.url?.trim()
const updated = await generateStoryboardVideo(job.id, frameIdx, {
prompt,
duration,
first_image: firstRef,
last_image: lastRef,
product_images: productRefs,
subject_image: firstRef,
scene_image: null,
product_image: productRefs[0] ?? null,
action_image: null,
source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
model,
size: "720x1280",
})
updateJobInList(updated)
void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("视频任务已进入 Video Gen 节点")
} catch (e) {
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [job, selectedFrames, updateJobInList])
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
const productRefs = (shot.product_images ?? []).filter(Boolean).slice(0, 4) as ImageRef[]
const subjectRefs = (shot.subject_images ?? []).filter(Boolean).slice(0, 7) as ImageRef[]
const primarySubject = shot.subject_image ?? subjectRefs[0] ?? null
if (!primarySubject || subjectRefs.length < 1 || productRefs.length < 4 || !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 {
const prompt = [
`产品融合镜头ID${shot.id || `shot-${frameIdx + 1}`}`,
`竖屏 9:16${duration.toFixed(1)}Seedance 参考图生视频。`,
"没有首帧和尾帧:请根据内置人物角色参考图、固定 SKG 产品图、场景/使用/享受描述直接生成完整视频。",
`人物角色:${shot.character_name || "透明骨架人"}。必须保持同一透明/半透明人体外壳、干净白色骨架、体型比例、服装风格和非恐怖广告气质。`,
`人物参考图:${subjectRefs.map((ref, index) => `角色图${index + 1}=${labelOf(ref, "透明骨架人参考")}`).join("")}`,
`产品角度图 1${labelOf(productRefs[0], "SKG 产品正面/主视角")}`,
`产品角度图 2${labelOf(productRefs[1], "SKG 产品侧面/斜侧视角")}`,
`产品角度图 3${labelOf(productRefs[2], "SKG 产品背面/细节视角")}`,
`产品角度图 4${labelOf(productRefs[3], "SKG 产品补充/底部或佩戴视角")}`,
"产品使用部位:这是颈部/肩颈按摩仪,只能自然佩戴或贴合在脖子、后颈、颈肩交界处;不要放到手臂、腰、腿、胸口、眼部或背景里。",
"比例尺寸产品应符合真实颈部按摩仪大小U 形结构环绕后颈但不能巨大化、缩小成饰品、嵌入身体、悬浮或穿透透明人体。",
"镜头语言:严格按描述里的出场方式、场景、景别、运镜、产品进入方式、佩戴贴合动作、使用过程和收尾方式执行。",
`场景/使用/享受描述:${shot.action_text.trim()}`,
TRANSPARENT_HUMAN_VIDEO_PROMPT,
"融合要求:产品必须自然出现在透明骨架人动作中,尺寸可信,透视一致,只贴合手部拿取和后颈/颈肩使用区域,不能悬浮、漂移、融化、扭曲或变成其他物体。",
"连续性:镜头必须完整连贯,中间不要跳切,不换角色,不换产品,不突然改变场景。",
"产品一致性:严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例;四张产品角度图是产品身份真源。",
"场景要求:背景、空间、光线和阴影要自然统一,不要出现水印、平台 UI、字幕或竞品包装。",
"商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。",
"禁止:文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、透明衣服但非透明身体。",
TRANSPARENT_HUMAN_NEGATIVE_PROMPT,
].join("\n")
const updated = await generateStoryboardVideo(job.id, frameIdx, {
prompt,
duration,
first_image: null,
last_image: null,
product_images: productRefs,
subject_image: primarySubject,
subject_images: subjectRefs,
scene_image: null,
product_image: productRefs[0] ?? null,
action_image: null,
source_ref: null,
model: "seedance",
size: "720x1280",
})
updateJobInList(updated)
void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("产品融合视频已进入 Video Gen 队列")
} catch (e) {
toast.error("产品融合生成失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [job, updateJobInList])
// 启动恢复URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const ids = (params.get("job") ?? "").split(",").filter(Boolean)
const restore = async () => {
let targetIds = ids
if (targetIds.length === 0) {
try {
const list = await listJobs()
targetIds = list.map((s) => s.id).reverse()
} catch {
return
}
}
if (targetIds.length === 0) return
const results = await Promise.all(targetIds.map((id) => getJob(id).catch(() => null)))
const valid = results.filter((j): j is Job => !!j)
if (valid.length > 0) {
setJobs(valid)
setActiveJobId(valid[valid.length - 1].id)
}
}
void restore()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 写回 URL所有 jobs id 用 , 分隔)
useEffect(() => {
const url = new URL(window.location.href)
if (jobs.length > 0) {
url.searchParams.set("job", jobs.map((j) => j.id).join(","))
} else {
url.searchParams.delete("job")
}
window.history.replaceState({}, "", url.toString())
}, [jobs.length])
// 恢复已保存的分镜选择:每个视频自己的 storyboard 帧仍保留在自己的编排上下文里。
useEffect(() => {
if (jobs.length === 0) return
setSelectedFramesByJob((prev) => {
let changed = false
const nextByJob = { ...prev }
for (const item of jobs) {
const persisted = item.frames.filter((f) => !!f.storyboard).map((f) => f.index)
if (persisted.length === 0) continue
const next = new Set(nextByJob[item.id] ?? [])
for (const idx of persisted) {
if (!next.has(idx)) {
next.add(idx)
changed = true
}
}
nextByJob[item.id] = [...next].sort((a, b) => a - b)
}
return changed ? nextByJob : prev
})
}, [jobs])
// 轮询 Job任一视频在下载 / 抽帧 / 生视频时都继续轮询,支持多个抽帧任务排队。
const prevStatusRef = useRef<string | null>(null)
useEffect(() => {
if (jobs.length === 0) return
// 状态切到 downloaded 时提示用户点解析(仅一次)
if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") {
toast.info("📥 视频已就绪 — 请点 Input 节点里的「点这里开始解析」按钮", { duration: 6000 })
}
prevStatusRef.current = job?.status ?? null
const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
const runningIds = jobs
.filter((item) => {
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
const runningAudio = item.audio_script?.status === "rewriting"
return runningVideo || runningAudio || !TERMINAL.includes(item.status)
})
.map((item) => item.id)
if (runningIds.length === 0) {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
return
}
pollRef.current = setInterval(async () => {
try {
const latestJobs = await Promise.all(runningIds.map((id) => getJob(id).catch(() => null)))
const byId = new Map(latestJobs.filter((item): item is Job => !!item).map((item) => [item.id, item]))
if (byId.size > 0) {
setJobs((prev) => prev.map((item) => byId.get(item.id) ?? item))
}
} catch { /* silent */ }
}, 1500)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [
job?.id,
job?.status,
jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
])
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
const handleToggleNodePin = useCallback((id: string) => {
setPinnedNodes((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
try { window.localStorage.setItem(NODE_PINS_KEY, JSON.stringify([...next])) } catch {}
return next
})
}, [])
const nodeData: NodeData = useMemo(() => ({
job,
jobs,
activeJobId,
submitting,
analyzing,
frameTargets,
frameCounts,
frameQualities,
selectedFrames,
expandedFrame,
framePanelScale,
framePanelPinned,
framePanelDock,
videoPanelJobId,
videoPanelScale,
videoPanelDock,
onSubmitUrl: handleSubmit,
onUploadFile: handleUpload,
onAnalyze: handleAnalyze,
onAnalyzeJob: handleAnalyzeJob,
onFrameTargetChange: handleFrameTargetChange,
onFrameCountChange: handleFrameCountChange,
onFrameQualityChange: handleFrameQualityChange,
onToggleFrame: handleToggleFrame,
onExpandFrame: handleOpenFramePanel,
onOpenFramePanel: handleOpenFramePanel,
onFramePanelScaleChange: handleFramePanelScaleChange,
onFramePanelPinnedChange: (pinned: boolean) => setFramePanelDock(pinned ? "left" : "canvas"),
onFramePanelDockChange: setFramePanelDock,
onCloseExpandedFrame: handleCloseExpandedFrame,
onAddManualFrame: handleAddManualFrame,
onAddManualFrameForJob: handleAddManualFrameForJob,
onOpenVideoPanel: handleOpenVideoPanel,
onCloseVideoPanel: () => setVideoPanelJobId(null),
onVideoPanelScaleChange: handleVideoPanelScaleChange,
onVideoPanelDockChange: setVideoPanelDock,
onSwitchJob: handleSwitchJob,
onJobUpdate: updateJobInList,
onDeleteJob: handleDeleteJob,
onDeleteFrame: handleDeleteFrame,
onDeleteFrameForJob: handleDeleteFrameForJob,
onDeleteGenerated: handleDeleteGenerated,
onDeleteVideo: handleDeleteVideo,
onDeleteCutout: handleDeleteCutout,
onOpenStoryboard: handleOpenStoryboard,
onOpenWorkbench: handleOpenWorkbench,
clipboard,
onCopyImage: handleCopyImage,
onGenerateProductFusionVideo: handleGenerateProductFusionVideo,
onTranscribeAudio: handleTranscribeAudio,
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])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const savedSizes = useMemo(() => loadNodeSizes(), [])
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
LAYOUT.map((n) => {
const s = savedSizes[n.id] ?? {}
const w = s.w ?? n.w
const h = s.h
const isPinned = pinnedNodes.has(n.id)
return {
id: n.id,
type: n.type,
position: { x: n.x, y: n.y },
data: nodeData,
draggable: !isPinned,
width: w,
...(typeof h === "number" ? { height: h } : {}),
style: { width: w, ...(typeof h === "number" ? { height: h } : {}) },
}
}),
)
// pinned 变化时同步每个节点 draggable
useEffect(() => {
setNodes((prev) => prev.map((n) =>
FLOATING_PANEL_IDS.has(n.id) ? n : { ...n, draggable: !pinnedNodes.has(n.id) },
))
}, [pinnedNodes, setNodes])
// 首次挂载、所有节点都被 ReactFlow 测量到后,自动整理一次(用户偏好:每次刷新自动归位)
const initialLayoutDone = useRef(false)
// 自动排版:保留每个节点的当前宽高(用户为方便看而调过的尺寸),只重新计算 position
// 让卡片按管线列分组、列间和列内留出统一间距,不重叠。
const handleResetLayout = useCallback(() => {
const zoom = flowRef.current?.getZoom?.() ?? 1
const measure = (id: string): { w: number; h: number } => {
const el = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null
if (!el) return { w: 320, h: 220 }
const r = el.getBoundingClientRect()
return { w: r.width / zoom, h: r.height / zoom }
}
// 按管线列分组(顶 → 底):图层 1 输入 → 5 合成
const COLUMNS: string[][] = [
["input"],
["visual", "audio"],
["compose"],
]
const GAP_X = 80
const GAP_Y = 56
const START_X = 40
const START_Y = 60
const positions: Record<string, { x: number; y: number }> = {}
let cursorX = START_X
for (const col of COLUMNS) {
let cursorY = START_Y
let colMaxW = 0
for (const id of col) {
const { w, h } = measure(id)
positions[id] = { x: cursorX, y: cursorY }
cursorY += h + GAP_Y
if (w > colMaxW) colMaxW = w
}
cursorX += colMaxW + GAP_X
}
setNodes((prev) => prev.map((n) => {
if (FLOATING_PANEL_IDS.has(n.id)) return n
const p = positions[n.id]
if (!p) return n
return { ...n, position: { x: p.x, y: p.y } }
}))
setTimeout(() => flowRef.current?.fitView?.({ padding: 0.12, duration: 400 }), 30)
toast.success("已自动排版 · 保留每个节点的尺寸")
}, [setNodes])
// 首次:等所有节点都被 ReactFlow 测量到n.measured 出现)后自动排版一次,避免叠在一起
useEffect(() => {
if (initialLayoutDone.current) return
const main = nodes.filter((n) => !FLOATING_PANEL_IDS.has(n.id))
if (main.length === 0) return
const allMeasured = main.every((n) => {
const m = (n as any).measured as { width?: number; height?: number } | undefined
return m && typeof m.width === "number" && typeof m.height === "number" && m.height > 0
})
if (!allMeasured) return
initialLayoutDone.current = true
setTimeout(() => handleResetLayout(), 80)
}, [nodes, handleResetLayout])
// 持久化每个节点宽 / 高到 localStorageKeyframePanelNode 自己管尺寸,不写回)
useEffect(() => {
const sizes: Record<string, NodeSize> = {}
for (const n of nodes) {
if (FLOATING_PANEL_IDS.has(n.id)) continue
const w = typeof n.width === "number" ? Math.round(n.width) : undefined
const h = typeof n.height === "number" ? Math.round(n.height) : undefined
if (w === undefined && h === undefined) continue
sizes[n.id] = { ...(w !== undefined ? { w } : {}), ...(h !== undefined ? { h } : {}) }
}
try { window.localStorage.setItem(NODE_SIZES_KEY, JSON.stringify(sizes)) } catch {}
}, [nodes])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>(
EDGES_RAW.map(([from, to], i) => ({
id: `e${i}`, source: from, target: to, animated: false, type: "default",
})),
)
// Job 数据变化时只更新节点 data 不动 position
useEffect(() => {
setNodes((prev) => prev.map((n) => ({ ...n, data: nodeData })))
}, [nodeData, setNodes])
// 关键帧详情面板是独立 ReactFlow 节点:可拖动、跟随画布缩放。
// 已打开时点击其他关键帧只切换内容,不移动用户拖好的面板位置。
useEffect(() => {
if (!job || expandedFrame === null) {
setNodes((prev) => prev.filter((n) => n.id !== KEYFRAME_PANEL_ID))
return
}
let shouldFocusNewPanel = false
setNodes((prev) => {
const visualNode = prev.find((n) => n.id === "visual")
const inputNode = prev.find((n) => n.id === "input")
const defaultPosition = {
x: (inputNode?.position.x ?? 40) - 820,
y: (visualNode?.position.y ?? 60),
}
const exists = prev.some((n) => n.id === KEYFRAME_PANEL_ID)
if (exists) {
return prev.map((n) => n.id === KEYFRAME_PANEL_ID
? {
...n,
data: nodeData,
draggable: !framePanelPinned,
dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
}
: n,
)
}
shouldFocusNewPanel = true
return [
...prev,
{
id: KEYFRAME_PANEL_ID,
type: "keyframePanel",
position: defaultPosition,
data: nodeData,
draggable: !framePanelPinned,
dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
selectable: true,
},
]
})
if (shouldFocusNewPanel && !framePanelPinned) {
window.setTimeout(() => {
flowRef.current?.fitView?.({
nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "visual" }],
padding: 0.18,
duration: 260,
})
}, 0)
}
}, [job?.id, expandedFrame, framePanelPinned, nodeData, setNodes])
// 视频抽帧面板也是独立 ReactFlow 节点:默认在 Input 附近打开,可拖动;吸附后走 portal 固定到屏幕边缘。
useEffect(() => {
const panelJob = videoPanelJobId ? jobs.find((j) => j.id === videoPanelJobId) ?? null : null
if (!panelJob?.video_url) {
setNodes((prev) => prev.filter((n) => n.id !== VIDEO_FRAME_PANEL_ID))
return
}
const focusKey = `${videoPanelJobId}:${videoPanelOpenTick}:${videoPanelDock}`
let panelWasCreated = false
setNodes((prev) => {
const inputNode = prev.find((n) => n.id === "input")
const defaultPosition = {
x: inputNode?.position.x ?? 40,
y: (inputNode?.position.y ?? 240) - 650,
}
const exists = prev.some((n) => n.id === VIDEO_FRAME_PANEL_ID)
if (exists) {
return prev.map((n) => n.id === VIDEO_FRAME_PANEL_ID
? {
...n,
data: nodeData,
draggable: videoPanelDock === "canvas",
dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined,
}
: n,
)
}
panelWasCreated = true
return [
...prev,
{
id: VIDEO_FRAME_PANEL_ID,
type: "videoFramePanel",
position: defaultPosition,
data: nodeData,
draggable: videoPanelDock === "canvas",
dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined,
selectable: true,
},
]
})
if (videoPanelDock === "canvas" && (panelWasCreated || lastVideoPanelFocusKey.current !== focusKey)) {
lastVideoPanelFocusKey.current = focusKey
window.setTimeout(() => {
flowRef.current?.fitView?.({
nodes: [{ id: VIDEO_FRAME_PANEL_ID }, { id: "input" }],
padding: 0.18,
duration: 260,
})
}, 0)
}
}, [videoPanelJobId, videoPanelOpenTick, videoPanelDock, jobs, nodeData, setNodes])
// 边的 animated 状态跟 Job 进度联动
useEffect(() => {
const doneOf: Record<string, boolean> = {
input: !!job?.video_url,
visual: !!job && (job.frames.length > 0 || (job.generated_videos?.length ?? 0) > 0),
asr: !!job && job.transcript.length > 0,
translate: !!job && (job.transcript.some((s) => s.zh) ?? false),
rewrite: !!job && !!job.audio_script?.rewritten_text,
}
setEdges((prev) => prev.map((e) => ({ ...e, animated: !!doneOf[e.source] })))
}, [job, setEdges])
return (
<>
<div className="canvas-bg" />
<main className="relative h-screen w-screen overflow-hidden flex">
{/* 自动整理布局 — 主题切换上方,一键恢复默认位置和宽度 */}
<div className="absolute z-30 pointer-events-auto" style={{ bottom: 228, left: 12 }}>
<button
type="button"
onClick={handleResetLayout}
className="glass-node h-9 w-9 inline-flex items-center justify-center rounded-xl"
style={{ width: 36, height: 36, padding: 0, borderRadius: 12 }}
title="自动排版 · 保留每个节点的尺寸,重新排好间距和列布局"
>
<LayoutGrid className="h-4 w-4" />
</button>
</div>
{/* 主题切换 — 左下角 Controls 上方(错开) */}
<div className="absolute z-30 pointer-events-auto" style={{ bottom: 180, left: 12 }}>
<ThemeToggle />
</div>
{/* 右区DAG 节点流图(原顶部 storyboard dock 已删除) */}
<section className="relative flex-1 min-h-0 flex flex-col">
<div className="relative flex-1 min-h-0">
{clientReady ? (
<ReactFlow
nodes={nodes}
edges={edges}
onInit={(instance) => { flowRef.current = instance }}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
colorMode={resolvedTheme === "light" ? "light" : "dark"}
fitView
fitViewOptions={{ padding: 0.12 }}
minZoom={0.2}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
<Controls position="bottom-left" />
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
</ReactFlow>
) : (
<div className="h-full w-full" suppressHydrationWarning />
)}
</div>
{clientReady && <AudioStrip job={audioStripJob} open={!!audioStripJob} onClose={() => setAudioStripJobId(null)} />}
</section>
<Toaster theme="system" position="top-center" />
</main>
</>
)
}