1223 lines
59 KiB
TypeScript
1223 lines
59 KiB
TypeScript
"use client"
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
import {
|
||
ReactFlow, Background, BackgroundVariant, Controls,
|
||
useNodesState, useEdgesState,
|
||
type Node, type Edge,
|
||
} from "@xyflow/react"
|
||
import { Toaster, toast } from "sonner"
|
||
import {
|
||
InputNode, VisualLabNode, AudioNode,
|
||
ComposeNode, KeyframePanelNode,
|
||
VideoFramePanelNode,
|
||
type CanvasPanelDock,
|
||
type NodeData,
|
||
} from "@/components/nodes"
|
||
import { AdRecreationBoard } from "@/components/ad-recreation-board"
|
||
import {
|
||
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
||
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset,
|
||
formatJobError, retryJobDownload,
|
||
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"
|
||
|
||
type FlowNodeData = NodeData & Record<string, unknown>
|
||
type StudioFlowNode = Node<FlowNodeData>
|
||
|
||
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 DIRECT_VIDEO_GENERATION_PAUSED = true
|
||
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: "极准",
|
||
}
|
||
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 VIDEO_READY_STATUSES: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
|
||
|
||
const PRODUCT_FUSION_WEARING_PROMPT = [
|
||
"Product placement must be physically correct:",
|
||
"The SKG device is a rigid opaque white U-shaped neck massager, not a soft scarf, necklace, cable, collar, sticker, implant, or transparent body part.",
|
||
"It must stay OUTSIDE the transparent skin shell. It should rest on the external surface around the back of the neck and upper shoulders, like a wearable collar-shaped device.",
|
||
"The curved bridge sits behind the neck. The two open arms come forward along the left and right sides of the neck, above the collarbones and near the upper trapezius.",
|
||
"The inner metal massage pads face the back/side of the neck. The outer glossy plastic shell remains visible and opaque.",
|
||
"Hands may hold the two ends while putting it on, then release or lightly press the side control button. Hands must not hide the device silhouette.",
|
||
"The product should occlude the transparent skin where it is in front, cast a small contact shadow, and keep realistic perspective and scale.",
|
||
"Keep a tiny readable separation between product edge and skeleton/skin whenever needed so it never looks embedded.",
|
||
].join("\n")
|
||
|
||
const PRODUCT_FUSION_PRODUCT_IDENTITY_PROMPT = [
|
||
"Product identity is strict:",
|
||
"The four SKG product reference images are real product photographs, not concept art and not style inspiration. Treat them as the immutable physical object to insert into the shot.",
|
||
"The four SKG product reference images are the single source of truth for the object. Preserve the same white U-shaped body, rounded arms, inner massage pads/nodes, side buttons, seams, glossy plastic material, thickness, proportions, and viewing angles.",
|
||
"Use visual compositing behavior: place the real product object onto the character externally, then match lighting, shadow, scale, and perspective around it. Do not redraw a new product from memory.",
|
||
"Do not redesign, stylize, simplify, melt, inflate, shrink, recolor, add logos/text/screens/wires/extra parts, or turn it into a generic neckband/headphone/medical brace.",
|
||
"If the product and character conflict, prioritize preserving the product shape and place it externally on the neck rather than merging it into the character.",
|
||
].join("\n")
|
||
|
||
const PRODUCT_FUSION_NEGATIVE_PROMPT = [
|
||
"Hard negatives for product fusion:",
|
||
"no product passing through the neck, no product inside the transparent body, no x-ray blending, no transparent product, no product becoming bones or skin, no product fused with spine/ribs/throat, no clipping through shoulders, no floating device, no melted device, no deformed U-shape, no wrong body part, no necklace/scarf/headphones/brace, no random replacement product.",
|
||
].join("\n")
|
||
|
||
function storyboardNeedsProduct(scene: StoryboardScene) {
|
||
if (scene.needs_product === false) return false
|
||
if (scene.needs_product === true) return true
|
||
const text = `${scene.visual_mode ?? ""} ${scene.product ?? ""} ${scene.product_placement ?? ""}`.toLowerCase()
|
||
return !/(不出现产品|不露产品|无需产品|不需要产品|无产品|no product|environment|person_only)/.test(text)
|
||
}
|
||
|
||
function storyboardNeedsSubject(scene: StoryboardScene) {
|
||
if (scene.needs_subject === false) return false
|
||
if (scene.needs_subject === true) return true
|
||
const text = `${scene.visual_mode ?? ""} ${scene.subject ?? ""}`.toLowerCase()
|
||
return !/(不需要人物|无人物|不出现人物|no person|product_only|environment)/.test(text)
|
||
}
|
||
|
||
// 合并 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 [jobs, setJobs] = useState<Job[]>([])
|
||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||
const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
|
||
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 [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("")
|
||
|
||
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 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)}`)
|
||
return created
|
||
} catch (e) {
|
||
toast.error("提交失败:" + (e instanceof Error ? e.message : String(e)))
|
||
return undefined
|
||
} 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)
|
||
setProductionJobIds((prev) => new Set(prev).add(created.id))
|
||
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
|
||
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 startProductionLanesForJob = useCallback(async (target: Job) => {
|
||
const videoReady = !!target.video_url && VIDEO_READY_STATUSES.includes(target.status)
|
||
if (!videoReady) return
|
||
|
||
const audioKey = `${target.id}:audio`
|
||
const hasAudioResult = !!target.audio_script?.source_text || target.transcript.length > 0
|
||
const audioRunning = target.status === "transcribing" || target.audio_script?.status === "rewriting"
|
||
if (!hasAudioResult && !audioRunning && !autoTriggeredRef.current.has(audioKey)) {
|
||
autoTriggeredRef.current.add(audioKey)
|
||
try {
|
||
const updated = await triggerTranscribe(target.id)
|
||
updateJobInList(updated)
|
||
toast.info("音频路已启动:字幕、讲话人、节奏和背景音同步解析")
|
||
} catch (e) {
|
||
autoTriggeredRef.current.delete(audioKey)
|
||
toast.error("音频解析启动失败:" + (e instanceof Error ? e.message : String(e)))
|
||
}
|
||
}
|
||
|
||
const visualKey = `${target.id}:visual`
|
||
const hasVisualResult = target.frames.length > 0
|
||
const visualRunning = target.status === "splitting"
|
||
if (!hasVisualResult && !visualRunning && !autoTriggeredRef.current.has(visualKey)) {
|
||
autoTriggeredRef.current.add(visualKey)
|
||
const frameTarget = frameTargets[target.id] ?? "motion"
|
||
const frameCount = frameCounts[target.id] ?? 12
|
||
const frameQuality = frameQualities[target.id] ?? "accurate"
|
||
try {
|
||
const updated = await analyzeJob(target.id, frameCount, frameTarget, "replace", frameQuality)
|
||
updateJobInList(updated)
|
||
toast.info(`视觉路已启动:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张参考帧`)
|
||
} catch (e) {
|
||
autoTriggeredRef.current.delete(visualKey)
|
||
toast.error("视觉抽帧启动失败:" + (e instanceof Error ? e.message : String(e)))
|
||
}
|
||
}
|
||
}, [frameCounts, frameQualities, frameTargets, 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
|
||
let target = created ?? job
|
||
if (!target) {
|
||
toast.info("先粘贴视频链接或选择一个素材任务")
|
||
return
|
||
}
|
||
if (!created && target.status === "failed") {
|
||
autoTriggeredRef.current.delete(`${target.id}:audio`)
|
||
autoTriggeredRef.current.delete(`${target.id}:visual`)
|
||
}
|
||
if (!created && target.status === "failed" && !target.video_url) {
|
||
try {
|
||
target = await retryJobDownload(target.id)
|
||
updateJobInList(target)
|
||
toast.info("已重新提交下载;下载完成后会自动跑音频文案路和视觉抽帧路")
|
||
} catch (e) {
|
||
toast.error("重新下载失败:" + (e instanceof Error ? e.message : String(e)))
|
||
return
|
||
}
|
||
}
|
||
setProductionJobIds((prev) => new Set(prev).add(target.id))
|
||
if (target.video_url) toast.success("已进入并行素材分析:音频文案路和视觉抽帧路会同步推进")
|
||
else toast.success("已进入并行素材分析:下载完成后自动跑音频文案路和视觉抽帧路")
|
||
void startProductionLanesForJob(target)
|
||
}, [handleSubmit, job, startProductionLanesForJob, updateJobInList])
|
||
|
||
useEffect(() => {
|
||
if (productionJobIds.size === 0) return
|
||
for (const item of jobs) {
|
||
if (!productionJobIds.has(item.id)) continue
|
||
const videoReady = !!item.video_url && VIDEO_READY_STATUSES.includes(item.status)
|
||
if (!videoReady) continue
|
||
void startProductionLanesForJob(item)
|
||
}
|
||
}, [jobs, productionJobIds, startProductionLanesForJob])
|
||
|
||
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
|
||
if (DIRECT_VIDEO_GENERATION_PAUSED) {
|
||
toast.info("视频生成调用已暂停:先生成并审核每条分镜的首帧/尾帧,再开放单条提交")
|
||
return
|
||
}
|
||
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.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
|
||
? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${nextFrame.index + 1} 尾帧` }
|
||
: null
|
||
const firstRef = scene.first_image ?? keyframeRef
|
||
const lastRef = scene.last_image ?? defaultLastRef
|
||
const needsProduct = storyboardNeedsProduct(scene)
|
||
const needsSubject = storyboardNeedsSubject(scene)
|
||
let productRefs = needsProduct ? (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) : []
|
||
if (needsProduct && 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[] = needsSubject ? (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 = needsSubject ? (subjectRefs[0] ?? firstRef) : null
|
||
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 产品短视频广告。`,
|
||
needsProduct
|
||
? productNature
|
||
: "本条分镜规划为非产品主镜头:可以只拍人物状态、场景过渡、情绪停点或节奏承接。不要硬插 SKG 产品、白底产品图、包装或任何随机商品。",
|
||
needsProduct && productRefs.length
|
||
? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。`
|
||
: needsProduct
|
||
? "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。"
|
||
: "本条不传产品参考图;如首尾帧里出现竞品、包装或非 SKG 商品,应弱化、移除或作为模糊背景,不要替换成 SKG 产品。",
|
||
needsProduct
|
||
? "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。"
|
||
: "首帧和尾帧用于控制画面起止、构图、场景和动作方向;本条没有产品任务,不要因为广告语而自动添加产品。",
|
||
"使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。",
|
||
"生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
|
||
"如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
|
||
"时间线:0%-15% 锁住首帧构图并轻微启动;15%-85% 做平滑连续运动;85%-100% 缓慢贴近尾帧并稳定收住。",
|
||
`镜头类型:${scene.visual_mode ?? "未标注"};需要人物=${needsSubject ? "是" : "否"};需要产品=${needsProduct ? "是" : "否"}。`,
|
||
scene.first_frame_plan ? `首帧规划:${scene.first_frame_plan}` : "",
|
||
scene.last_frame_plan ? `尾帧规划:${scene.last_frame_plan}` : "",
|
||
scene.product_placement ? `产品出现方式:${scene.product_placement}` : "",
|
||
needsSubject
|
||
? TRANSPARENT_HUMAN_VIDEO_PROMPT
|
||
: "本条不传人物主体参考图;如果画面需要人物,只能作为背景、手部局部或模糊生活方式元素,不要生成主角式透明骨架人。",
|
||
`主体改造:${subjectDirection}`,
|
||
needsProduct
|
||
? `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`
|
||
: `产品处理:${productDirection} 本条不需要露出 SKG 产品,不要硬插产品、包装、瓶罐、医疗器械或随机商品。`,
|
||
`场景改造:${sceneDirection}`,
|
||
`连续动作和镜头:${actionDirection}`,
|
||
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
|
||
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
|
||
needsProduct ? `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}` : "SKG 产品参考:本条不使用产品参考图。",
|
||
needsSubject
|
||
? (subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。")
|
||
: "关键元素 6 视图参考:本条不使用人物主体参考图。",
|
||
sourceScene,
|
||
sourceStyle,
|
||
sourceObjects,
|
||
needsProduct ? "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。" : "",
|
||
needsProduct ? "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。" : "",
|
||
needsSubject || needsProduct ? "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。" : "节奏要求:作为过渡镜头时只负责情绪、空间和节奏承接,不承诺疗效,不强行展示使用动作。",
|
||
needsProduct ? "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。" : "运动要求:动作幅度小而连续,速度均匀,构图从首帧自然过渡到尾帧,不突然添加人物或产品。",
|
||
"商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。",
|
||
"禁止:字幕、文字、平台 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: primarySubjectRef,
|
||
subject_images: subjectRefs,
|
||
scene_image: null,
|
||
product_image: needsProduct ? (productRefs[0] ?? null) : 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("视频任务已进入候选片段")
|
||
} catch (e) {
|
||
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||
}
|
||
}, [ensureDefaultProductRefs, job, selectedFrames, updateJobInList])
|
||
|
||
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
|
||
if (DIRECT_VIDEO_GENERATION_PAUSED) {
|
||
toast.info("视频生成调用已暂停:当前只做首尾帧和素材规划")
|
||
return
|
||
}
|
||
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 产品补充/底部或佩戴视角")}。`,
|
||
PRODUCT_FUSION_PRODUCT_IDENTITY_PROMPT,
|
||
PRODUCT_FUSION_WEARING_PROMPT,
|
||
"Camera direction: follow the scene description, but always stage the product as a separate external wearable object. Show the hands placing or adjusting the device on the outside of the neck; do not imply the product is merged with the transparent body.",
|
||
`场景/使用/享受描述:${shot.action_text.trim()}`,
|
||
TRANSPARENT_HUMAN_VIDEO_PROMPT,
|
||
"Fusion rule: this is product placement, not body fusion. The product is an opaque physical device worn outside the body with believable contact shadow, occlusion, scale, and perspective.",
|
||
"连续性:镜头必须完整连贯,中间不要跳切,不换角色,不换产品,不突然改变场景。",
|
||
"场景要求:背景、空间、光线和阴影要自然统一,不要出现水印、平台 UI、字幕或竞品包装。",
|
||
"商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。",
|
||
PRODUCT_FUSION_NEGATIVE_PROMPT,
|
||
"禁止:文字、水印、随机品牌、非 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: null,
|
||
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("视频已下载,音频解析会自动开始;也可以在右侧手动重试", { duration: 6000 })
|
||
}
|
||
if (job?.status === "failed" && prevStatusRef.current !== "failed") {
|
||
toast.error(formatJobError(job.error) || "任务失败", { duration: 10000 })
|
||
}
|
||
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,
|
||
onStartProduction: handleStartProduction,
|
||
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,
|
||
pinnedNodes,
|
||
onToggleNodePin: 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, pinnedNodes, handleToggleNodePin])
|
||
|
||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
||
const [nodes, setNodes] = useNodesState<StudioFlowNode>(
|
||
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 as FlowNodeData,
|
||
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])
|
||
|
||
// 持久化每个节点宽 / 高到 localStorage(KeyframePanelNode 自己管尺寸,不写回)
|
||
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 [, setEdges] = 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 as FlowNodeData })))
|
||
}, [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 as FlowNodeData,
|
||
draggable: !framePanelPinned,
|
||
dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
|
||
}
|
||
: n,
|
||
)
|
||
}
|
||
shouldFocusNewPanel = true
|
||
return [
|
||
...prev,
|
||
{
|
||
id: KEYFRAME_PANEL_ID,
|
||
type: "keyframePanel",
|
||
position: defaultPosition,
|
||
data: nodeData as FlowNodeData,
|
||
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 as FlowNodeData,
|
||
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 as FlowNodeData,
|
||
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 flex h-screen w-screen overflow-hidden">
|
||
<AdRecreationBoard data={nodeData} onGenerateVideo={handleQuickGenerateVideo} />
|
||
<Toaster theme="system" position="top-center" />
|
||
</main>
|
||
</>
|
||
)
|
||
}
|