From 2144c374bdeb2f2b6f0d89d6e28c4115a786227e Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 14 May 2026 03:20:46 +0800 Subject: [PATCH] auto-save 2026-05-14 03:20 (~4) --- .memory/worklog.json | 13 ++ docs/source-analysis.html | 21 ++- web/app/page.tsx | 121 +++++++++++++--- web/components/nodes/index.tsx | 248 ++++++++++++++++++++++++++++++++- 4 files changed, 376 insertions(+), 27 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 9c3f7f0..8693e4f 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -3024,6 +3024,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 03:09 (~3)", "files_changed": 1 + }, + { + "ts": "2026-05-14T03:15:11+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 03:14 (~2)", + "hash": "b8fa19a", + "files_changed": 2 + }, + { + "ts": "2026-05-13T19:18:49Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 03:14 (~2)", + "files_changed": 3 } ] } diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 3966668..3e4aef7 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -569,8 +569,8 @@

前端核心

- - + + @@ -596,6 +596,7 @@ web/app/page.tsx -> ReactFlow 节点:web/components/nodes/index.tsx -> 主画布:Input → VisualLab / Audio → Compose + -> 画布内视频抽帧面板:InputNode 双击视频缩略图打开 videoFramePanel -> 画布内镜头拆解面板:VisualLabNode 打开 keyframePanel,内嵌 web/components/lightbox.tsx -> 分镜工作台:web/components/storyboard-workbench.tsx(底层保留) -> API 契约:web/lib/api.ts @@ -722,9 +723,9 @@ api/main.py - + - + @@ -816,6 +817,18 @@ api/main.py

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-14 · 输入视频双击改为画布抽帧面板

+ Canvas Panel + Input +
+
+

问题:输入视频缩略图双击原来只在 Input 节点上方展开一个临时播放器,不能作为无限画布工作台移动、找回或吸附,后续其他板块也无法复用这种交互。

+

改动:新增 videoFramePanel ReactFlow 节点和 VideoFramePanelNode。双击输入视频缩略图会打开/找回画布内抽帧面板,面板可拖动、右下角缩放,也可一键吸附到左侧、右侧或底部边缘;面板内支持播放、时间轴定位、当前时间抽帧和查看已抽关键帧。

+

影响:web/app/page.tsxweb/components/nodes/index.tsxdocs/source-analysis.html。原固定全屏 VideoLightbox 不再从 Input 双击路径进入;后续处理板块应复用同类画布工作面板语义。

+
+

2026-05-14 · Hover 大预览尺寸信息增强

diff --git a/web/app/page.tsx b/web/app/page.tsx index ad2da43..fe79f65 100644 --- a/web/app/page.tsx +++ b/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(null) const [framePanelScale, setFramePanelScale] = useState(1) const [framePanelPinned, setFramePanelPinned] = useState(false) - const [videoLightboxOpen, setVideoLightboxOpen] = useState(false) + const [videoPanelJobId, setVideoPanelJobId] = useState(null) + const [videoPanelScale, setVideoPanelScale] = useState(1) + const [videoPanelDock, setVideoPanelDock] = useState("canvas") + const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0) const [storyboardFrame, setStoryboardFrame] = useState(null) const [workbenchOpen, setWorkbenchOpen] = useState(false) const [clipboard, setClipboard] = useState(null) const flowRef = useRef(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 = {} 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 = { @@ -721,14 +806,6 @@ export default function Home() { - {/* Video lightbox — InputNode 缩略图点击进入 */} - setVideoLightboxOpen(false)} - onAddFrame={handleAddManualFrame} - /> - ) diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 4f78d1b..9e62a49 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -51,7 +51,6 @@ export interface NodeData { onCloseVideoPanel?: () => void onVideoPanelScaleChange?: (scale: number) => void onVideoPanelDockChange?: (dock: CanvasPanelDock) => void - onOpenVideoLightbox: () => void onSwitchJob: (id: string) => void onJobUpdate: (j: Job) => void onDeleteJob?: (id: string) => void @@ -627,6 +626,253 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an ) } +/* ============================================================ + 1b. VideoFramePanelNode — 画布内视频抽帧工作面板 + ============================================================ */ +export function VideoFramePanelNode({ data }: any) { + const d: NodeData = data + const { getZoom } = useReactFlow() + const panelJob = d.videoPanelJobId + ? d.jobs.find((j) => j.id === d.videoPanelJobId) ?? null + : null + const videoRef = useRef(null) + const scale = d.videoPanelScale ?? 1 + const dock = d.videoPanelDock ?? "canvas" + const docked = dock !== "canvas" + const [currentT, setCurrentT] = useState(0) + const [adding, setAdding] = useState(false) + + useEffect(() => { + setCurrentT(0) + setAdding(false) + }, [panelJob?.id]) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") d.onCloseVideoPanel?.() + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [d]) + + if (!panelJob?.video_url) return null + + const panelWidth = Math.round(760 * scale) + const panelHeight = Math.round(620 * scale) + const bodyHeight = Math.max(500, panelHeight - 28) + const duration = panelJob.duration ?? 0 + const frames = [...panelJob.frames].sort((a, b) => a.timestamp - b.timestamp) + const aspect = panelJob.width && panelJob.height ? `${panelJob.width}/${panelJob.height}` : "9/16" + const dockText: Record = { + canvas: "画布模式", + left: "吸附左侧", + right: "吸附右侧", + bottom: "吸附底部", + } + + const setScale = (next: number) => { + const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2)))) + d.onVideoPanelScaleChange?.(clamped) + } + + const startResize = (e: ReactPointerEvent) => { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startY = e.clientY + const startScale = scale + const zoom = docked ? 1 : getZoom() + const onMove = (ev: PointerEvent) => { + const dx = (ev.clientX - startX) / zoom + const dy = (ev.clientY - startY) / zoom + const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 620 + setScale(startScale + delta) + } + const onUp = () => { + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) + } + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) + } + + const seekTo = (next: number) => { + const t = clamp(next, 0, Math.max(duration, 0)) + setCurrentT(t) + if (videoRef.current) videoRef.current.currentTime = t + } + + const addCurrentFrame = async () => { + const t = videoRef.current?.currentTime ?? currentT + setAdding(true) + try { + if (d.onAddManualFrameForJob) await d.onAddManualFrameForJob(panelJob.id, t) + else await d.onAddManualFrame(t) + } finally { + setAdding(false) + } + } + + const dockButtonClass = (value: CanvasPanelDock) => + `nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${ + dock === value + ? "bg-white text-violet-700 shadow" + : "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white" + }` + + const panel = ( +
+
+
+ + 视频抽帧 · Input + + {panelJob.width}×{panelJob.height} · {duration.toFixed(1)}s + +
+
+ + {dockText[dock]} + + + + + + + + + +
+
+
e.stopPropagation()}> +
+
+
+
+
+
+ 当前 {currentT.toFixed(2)}s +
+
+ {frames.length} 张关键帧 +
+
+ seekTo(Number(e.target.value))} + className="w-full accent-violet-400" + aria-label="视频时间轴" + /> + +
+ +
+
+
已抽关键帧
+
点击缩略图定位时间
+
+ {frames.length > 0 ? ( +
+ {frames.map((f) => ( + + ))} +
+ ) : ( +
+ 还没有关键帧,拖动时间轴后点击抽帧。 +
+ )} +
+
+
+ +
+ ) + + if (docked && typeof document !== "undefined") { + const fixedStyle = + dock === "left" + ? { left: 16, top: 72 } + : dock === "right" + ? { right: 16, top: 72 } + : { left: "50%", bottom: 16, transform: "translateX(-50%)" } + return createPortal( +
+ {panel} +
, + document.body, + ) + } + + return panel +} + /* ============================================================ 2. DownloadNode ============================================================ */
web/app/page.tsx产品工作台主状态:jobs、activeJobId、selectedFrames、clipboard、ReactFlow 节点和边。
web/components/nodes/index.tsxDAG 节点定义:Input、VisualLab、Audio、Compose、KeyframePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。
web/app/page.tsx产品工作台主状态:jobs、activeJobId、selectedFrames、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。
web/components/nodes/index.tsxDAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。
web/components/lightbox.tsx镜头拆解和元素提取的主工作面板:清洗、识别、元素编辑、区域提取、抠图。
web/components/storyboard-bar.tsx顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。
web/components/storyboard-workbench.tsx顶部分镜编排条下方的明细区:4 图槽、改造目标、时长、自动保存。
输入 Input创建/上传任务,显示视频就绪,触发解析。创建/上传任务,显示视频就绪,触发解析;双击视频缩略图打开画布内抽帧面板。 不要自动一路跑到 ASR 或生图;用户需要控制解析节奏。page.tsxInputNodeapi/main.pypage.tsxInputNodeVideoFramePanelNodeapi/main.py
画面工作台 Visual Lab