auto-save 2026-05-14 03:20 (~4)

This commit is contained in:
2026-05-14 03:20:46 +08:00
parent b8fa19aeaa
commit 2144c374bd
4 changed files with 376 additions and 27 deletions

View File

@@ -11,6 +11,8 @@ 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"
@@ -19,7 +21,6 @@ import {
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
const NODE_TYPES = {
input: InputNode,
@@ -27,9 +28,12 @@ const NODE_TYPES = {
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])
// 合并 input + download + split 为一个节点
// 分叉:上路 input → visual lab ↘
@@ -85,11 +89,15 @@ export default function Home() {
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
const [framePanelScale, setFramePanelScale] = useState(1)
const [framePanelPinned, setFramePanelPinned] = useState(false)
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
const [videoPanelJobId, setVideoPanelJobId] = useState<string | null>(null)
const [videoPanelScale, setVideoPanelScale] = useState(1)
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("canvas")
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) => {
@@ -165,16 +173,31 @@ export default function Home() {
}
}, [job?.id])
const handleAddManualFrame = useCallback(async (t: number) => {
if (!job) return
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
try {
const updated = await addManualFrame(job.id, t)
setJob(updated)
const updated = await addManualFrame(jobId, t)
setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item))
setActiveJobId(updated.id)
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length}`)
} catch (e) {
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [job?.id])
}, [])
const handleAddManualFrame = useCallback(async (t: number) => {
if (!job) return
await handleAddManualFrameForJob(job.id, t)
}, [job?.id, handleAddManualFrameForJob])
const handleOpenVideoPanel = useCallback((jobId: string) => {
setActiveJobId(jobId)
setVideoPanelJobId(jobId)
setVideoPanelOpenTick((n) => n + 1)
}, [])
const handleVideoPanelScaleChange = useCallback((scale: number) => {
setVideoPanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
}, [])
const handleToggleFrame = useCallback((idx: number) => {
setSelectedFrames((prev) => {
@@ -236,6 +259,7 @@ export default function Home() {
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)
@@ -244,7 +268,6 @@ export default function Home() {
setActiveJobId(fallback?.id ?? null)
setSelectedFrames(new Set())
setExpandedFrame(null)
setVideoLightboxOpen(false)
setStoryboardFrame(null)
setWorkbenchOpen(false)
}
@@ -467,6 +490,9 @@ export default function Home() {
expandedFrame,
framePanelScale,
framePanelPinned,
videoPanelJobId,
videoPanelScale,
videoPanelDock,
onSubmitUrl: handleSubmit,
onUploadFile: handleUpload,
onAnalyze: handleAnalyze,
@@ -477,7 +503,11 @@ export default function Home() {
onFramePanelPinnedChange: setFramePanelPinned,
onCloseExpandedFrame: () => setExpandedFrame(null),
onAddManualFrame: handleAddManualFrame,
onOpenVideoLightbox: () => setVideoLightboxOpen(true),
onAddManualFrameForJob: handleAddManualFrameForJob,
onOpenVideoPanel: handleOpenVideoPanel,
onCloseVideoPanel: () => setVideoPanelJobId(null),
onVideoPanelScaleChange: handleVideoPanelScaleChange,
onVideoPanelDockChange: setVideoPanelDock,
onSwitchJob: handleSwitchJob,
onJobUpdate: setJob as any,
onDeleteJob: handleDeleteJob,
@@ -493,7 +523,7 @@ export default function Home() {
onCopyImage: handleCopyImage,
pinnedNodes,
onToggleNodePin: handleToggleNodePin,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const savedSizes = useMemo(() => loadNodeSizes(), [])
@@ -519,7 +549,7 @@ export default function Home() {
// pinned 变化时同步每个节点 draggable
useEffect(() => {
setNodes((prev) => prev.map((n) =>
n.id === KEYFRAME_PANEL_ID ? n : { ...n, draggable: !pinnedNodes.has(n.id) },
FLOATING_PANEL_IDS.has(n.id) ? n : { ...n, draggable: !pinnedNodes.has(n.id) },
))
}, [pinnedNodes, setNodes])
@@ -563,7 +593,7 @@ export default function Home() {
}
setNodes((prev) => prev.map((n) => {
if (n.id === KEYFRAME_PANEL_ID) return 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 } }
@@ -575,7 +605,7 @@ export default function Home() {
// 首次:等所有节点都被 ReactFlow 测量到n.measured 出现)后自动排版一次,避免叠在一起
useEffect(() => {
if (initialLayoutDone.current) return
const main = nodes.filter((n) => n.id !== KEYFRAME_PANEL_ID)
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
@@ -590,7 +620,7 @@ export default function Home() {
useEffect(() => {
const sizes: Record<string, NodeSize> = {}
for (const n of nodes) {
if (n.id === KEYFRAME_PANEL_ID) continue
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
@@ -662,6 +692,61 @@ export default function Home() {
}
}, [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> = {
@@ -721,14 +806,6 @@ export default function Home() {
<Toaster theme="system" position="bottom-center" />
{/* Video lightbox — InputNode 缩略图点击进入 */}
<VideoLightbox
jobId={job?.id ?? null}
open={videoLightboxOpen}
onClose={() => setVideoLightboxOpen(false)}
onAddFrame={handleAddManualFrame}
/>
</main>
</>
)