auto-save 2026-05-14 03:20 (~4)
This commit is contained in:
121
web/app/page.tsx
121
web/app/page.tsx
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user