auto-save 2026-05-14 10:36 (~5)
This commit is contained in:
178
web/app/page.tsx
178
web/app/page.tsx
@@ -103,8 +103,13 @@ export default function Home() {
|
||||
const [frameTargets, setFrameTargets] = useState<Record<string, FrameExtractTarget>>({})
|
||||
const [frameCounts, setFrameCounts] = useState<Record<string, number>>({})
|
||||
const [frameQualities, setFrameQualities] = useState<Record<string, FrameExtractQuality>>({})
|
||||
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
||||
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
||||
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"
|
||||
@@ -112,28 +117,32 @@ export default function Home() {
|
||||
const [videoPanelScale, setVideoPanelScale] = useState(1)
|
||||
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left")
|
||||
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
|
||||
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
||||
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
||||
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
||||
const flowRef = useRef<any>(null)
|
||||
const lastVideoPanelFocusKey = useRef("")
|
||||
|
||||
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
|
||||
const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => {
|
||||
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 current = prev.find((j) => j.id === activeJobId) ?? null
|
||||
const next = typeof updater === "function" ? (updater as (p: Job | null) => Job | null)(current) : updater
|
||||
if (!next) return prev
|
||||
const idx = prev.findIndex((j) => j.id === next.id)
|
||||
if (idx < 0) {
|
||||
setActiveJobId(next.id)
|
||||
return [...prev, next]
|
||||
}
|
||||
const idx = prev.findIndex((j) => j.id === updated.id)
|
||||
if (idx < 0) return [...prev, updated]
|
||||
const arr = [...prev]
|
||||
arr[idx] = next
|
||||
arr[idx] = updated
|
||||
return arr
|
||||
})
|
||||
}, [activeJobId])
|
||||
}, [])
|
||||
|
||||
// 新增 job + 设为 active
|
||||
const addJob = useCallback((j: Job) => {
|
||||
@@ -143,13 +152,11 @@ export default function Home() {
|
||||
|
||||
const handleSwitchJob = useCallback((id: string) => {
|
||||
setActiveJobId(id)
|
||||
setSelectedFrames(new Set())
|
||||
}, [])
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const handleSubmit = useCallback(async (url: string) => {
|
||||
setSubmitting(true)
|
||||
setSelectedFrames(new Set())
|
||||
try {
|
||||
const created = await createJob(url)
|
||||
addJob(created)
|
||||
@@ -163,7 +170,6 @@ export default function Home() {
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
setSubmitting(true)
|
||||
setSelectedFrames(new Set())
|
||||
try {
|
||||
toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
|
||||
const created = await uploadJob(file)
|
||||
@@ -185,7 +191,7 @@ export default function Home() {
|
||||
const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
|
||||
setActiveJobId(jobId)
|
||||
setAnalyzing(true)
|
||||
if (mode === "replace") setSelectedFrames(new Set())
|
||||
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} 张`)
|
||||
@@ -200,7 +206,7 @@ export default function Home() {
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}, [jobs, frameCounts, frameQualities, frameTargets])
|
||||
}, [jobs, frameCounts, frameQualities, frameTargets, clearWorkflowStateForJob])
|
||||
|
||||
const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
|
||||
if (!job) return
|
||||
@@ -222,13 +228,13 @@ export default function Home() {
|
||||
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
|
||||
try {
|
||||
const updated = await addManualFrame(jobId, t)
|
||||
setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item))
|
||||
setActiveJobId(updated.id)
|
||||
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
|
||||
@@ -247,18 +253,43 @@ export default function Home() {
|
||||
}, [])
|
||||
|
||||
const handleToggleFrame = useCallback((idx: number) => {
|
||||
setSelectedFrames((prev) => {
|
||||
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")
|
||||
setExpandedFrame(idx)
|
||||
}, [expandedFrame])
|
||||
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)))))
|
||||
@@ -268,21 +299,20 @@ export default function Home() {
|
||||
const wasActive = activeJobId === jobId
|
||||
try {
|
||||
const updated = await deleteFrame(jobId, idx)
|
||||
setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item))
|
||||
setActiveJobId(updated.id)
|
||||
setSelectedFrames((prev) => {
|
||||
if (!wasActive) return new Set()
|
||||
updateJobInList(updated)
|
||||
setSelectedFramesForJob(jobId, (prev) => {
|
||||
if (!prev.has(idx)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(idx)
|
||||
return next
|
||||
})
|
||||
if (!wasActive || expandedFrame === idx) setExpandedFrame(null)
|
||||
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, expandedFrame])
|
||||
}, [activeJobId, setSelectedFramesForJob, updateJobInList])
|
||||
|
||||
const handleDeleteFrame = useCallback(async (idx: number) => {
|
||||
if (!activeJobId) return
|
||||
@@ -293,23 +323,23 @@ export default function Home() {
|
||||
if (!activeJobId) return
|
||||
try {
|
||||
const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId)
|
||||
setJob(updated)
|
||||
updateJobInList(updated)
|
||||
toast.success("生成图已删除")
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}, [activeJobId, setJob])
|
||||
}, [activeJobId, updateJobInList])
|
||||
|
||||
const handleDeleteVideo = useCallback(async (videoId: string) => {
|
||||
if (!activeJobId) return
|
||||
try {
|
||||
const updated = await deleteGeneratedVideo(activeJobId, videoId)
|
||||
setJob(updated)
|
||||
updateJobInList(updated)
|
||||
toast.success("视频任务已删除")
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}, [activeJobId, setJob])
|
||||
}, [activeJobId, updateJobInList])
|
||||
|
||||
const handleDeleteJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
@@ -321,13 +351,17 @@ export default function Home() {
|
||||
if (activeJobId === jobId) {
|
||||
const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null
|
||||
setActiveJobId(fallback?.id ?? null)
|
||||
setSelectedFrames(new Set())
|
||||
setExpandedFrame(null)
|
||||
setStoryboardFrame(null)
|
||||
setWorkbenchOpen(false)
|
||||
}
|
||||
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)))
|
||||
@@ -338,12 +372,12 @@ export default function Home() {
|
||||
if (!activeJobId) return
|
||||
try {
|
||||
const updated = await deleteCutout(activeJobId, frameIdx, elementId, cutoutId)
|
||||
setJob(updated)
|
||||
updateJobInList(updated)
|
||||
toast.success("元素提取图已删除")
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}, [activeJobId, setJob])
|
||||
}, [activeJobId, updateJobInList])
|
||||
|
||||
const handleCopyImage = useCallback((ref: ImageRef) => {
|
||||
setClipboard(ref)
|
||||
@@ -439,13 +473,13 @@ export default function Home() {
|
||||
model,
|
||||
size: "720x1280",
|
||||
})
|
||||
setJob(updated)
|
||||
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, setJob])
|
||||
}, [job, selectedFrames, updateJobInList])
|
||||
|
||||
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
|
||||
if (!job) return
|
||||
@@ -495,13 +529,13 @@ export default function Home() {
|
||||
model: "seedance",
|
||||
size: "720x1280",
|
||||
})
|
||||
setJob(updated)
|
||||
updateJobInList(updated)
|
||||
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
||||
toast.success("产品融合视频已进入 Video Gen 队列")
|
||||
} catch (e) {
|
||||
toast.error("产品融合生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}, [job, setJob])
|
||||
}, [job, updateJobInList])
|
||||
|
||||
// 启动恢复:URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾)
|
||||
useEffect(() => {
|
||||
@@ -540,23 +574,27 @@ export default function Home() {
|
||||
window.history.replaceState({}, "", url.toString())
|
||||
}, [jobs.length])
|
||||
|
||||
// 恢复已保存的分镜选择:刷新页面后,已有 storyboard 的帧仍应出现在顶部编排栏。
|
||||
// 恢复已保存的分镜选择:每个视频自己的 storyboard 帧仍保留在自己的编排上下文里。
|
||||
useEffect(() => {
|
||||
if (!job || job.frames.length === 0) return
|
||||
const persisted = job.frames.filter((f) => !!f.storyboard).map((f) => f.index)
|
||||
if (persisted.length === 0) return
|
||||
setSelectedFrames((prev) => {
|
||||
if (jobs.length === 0) return
|
||||
setSelectedFramesByJob((prev) => {
|
||||
let changed = false
|
||||
const next = new Set(prev)
|
||||
for (const idx of persisted) {
|
||||
if (!next.has(idx)) {
|
||||
next.add(idx)
|
||||
changed = true
|
||||
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 ? next : prev
|
||||
return changed ? nextByJob : prev
|
||||
})
|
||||
}, [job?.id, job?.frames])
|
||||
}, [jobs])
|
||||
|
||||
// 轮询 Job:任一视频在下载 / 抽帧 / 生视频时都继续轮询,支持多个抽帧任务排队。
|
||||
const prevStatusRef = useRef<string | null>(null)
|
||||
@@ -572,7 +610,8 @@ export default function Home() {
|
||||
const runningIds = jobs
|
||||
.filter((item) => {
|
||||
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||
return runningVideo || !TERMINAL.includes(item.status)
|
||||
const runningAudio = item.audio_script?.status === "rewriting"
|
||||
return runningVideo || runningAudio || !TERMINAL.includes(item.status)
|
||||
})
|
||||
.map((item) => item.id)
|
||||
|
||||
@@ -593,7 +632,7 @@ export default function Home() {
|
||||
}, [
|
||||
job?.id,
|
||||
job?.status,
|
||||
jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
|
||||
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()))
|
||||
@@ -631,12 +670,12 @@ export default function Home() {
|
||||
onFrameCountChange: handleFrameCountChange,
|
||||
onFrameQualityChange: handleFrameQualityChange,
|
||||
onToggleFrame: handleToggleFrame,
|
||||
onExpandFrame: setExpandedFrame,
|
||||
onExpandFrame: handleOpenFramePanel,
|
||||
onOpenFramePanel: handleOpenFramePanel,
|
||||
onFramePanelScaleChange: handleFramePanelScaleChange,
|
||||
onFramePanelPinnedChange: (pinned: boolean) => setFramePanelDock(pinned ? "left" : "canvas"),
|
||||
onFramePanelDockChange: setFramePanelDock,
|
||||
onCloseExpandedFrame: () => setExpandedFrame(null),
|
||||
onCloseExpandedFrame: handleCloseExpandedFrame,
|
||||
onAddManualFrame: handleAddManualFrame,
|
||||
onAddManualFrameForJob: handleAddManualFrameForJob,
|
||||
onOpenVideoPanel: handleOpenVideoPanel,
|
||||
@@ -644,24 +683,21 @@ export default function Home() {
|
||||
onVideoPanelScaleChange: handleVideoPanelScaleChange,
|
||||
onVideoPanelDockChange: setVideoPanelDock,
|
||||
onSwitchJob: handleSwitchJob,
|
||||
onJobUpdate: setJob as any,
|
||||
onJobUpdate: updateJobInList,
|
||||
onDeleteJob: handleDeleteJob,
|
||||
onDeleteFrame: handleDeleteFrame,
|
||||
onDeleteFrameForJob: handleDeleteFrameForJob,
|
||||
onDeleteGenerated: handleDeleteGenerated,
|
||||
onDeleteVideo: handleDeleteVideo,
|
||||
onDeleteCutout: handleDeleteCutout,
|
||||
onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
|
||||
onOpenWorkbench: (idx?: number) => {
|
||||
if (typeof idx === "number") setStoryboardFrame(idx)
|
||||
setWorkbenchOpen(true)
|
||||
},
|
||||
onOpenStoryboard: handleOpenStoryboard,
|
||||
onOpenWorkbench: handleOpenWorkbench,
|
||||
clipboard,
|
||||
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, clipboard, handleCopyImage, handleGenerateProductFusionVideo, 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, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, pinnedNodes, handleToggleNodePin])
|
||||
|
||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
||||
|
||||
Reference in New Issue
Block a user