diff --git a/.memory/worklog.json b/.memory/worklog.json index 974f79a..897242a 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -3050,6 +3050,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 03:20 (~4)", "files_changed": 1 + }, + { + "ts": "2026-05-14T03:26:17+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 03:26 (~3)", + "hash": "65f81ef", + "files_changed": 3 + }, + { + "ts": "2026-05-13T19:28:49Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 03:26 (~3)", + "files_changed": 4 } ] } diff --git a/docs/source-analysis.html b/docs/source-analysis.html index c2209fd..3637f7e 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -817,6 +817,42 @@ api/main.py

变更记录

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

+
+
+

2026-05-14 · 画面工作台详情面板默认左侧吸附

+ Visual Lab + Dock +
+
+

问题:画面工作台点击关键帧后,关键帧详情 / 元素提取面板仍默认作为画布节点出现,还会触发 fitView 拉动画布;和视频抽帧面板默认左侧吸附的规则不一致。

+

改动:framePanelPinned 初始值改为 true;从关闭状态打开关键帧详情时默认吸附左侧。若面板已打开,用户取消钉住后继续切换关键帧,不会被强制吸回左侧;默认吸附状态下也不再触发画布 fitView

+

影响:web/app/page.tsxdocs/source-analysis.html。画面工作台处理面板现在与视频抽帧面板保持同一默认策略:先贴左侧,需要时再切回画布。

+
+
+
+
+

2026-05-14 · 视频抽帧面板默认左侧吸附

+ Canvas Panel + Dock +
+
+

问题:视频抽帧面板默认以画布节点方式打开时,用户还要再点一次吸附左侧,和“处理面板先贴边、需要时再拖回画布”的工作习惯不一致。

+

改动:videoPanelDock 初始值改为 left;从关闭状态双击输入视频缩略图时默认吸附左侧。面板已打开后,用户手动切换到画布 / 右侧 / 底部不会被找回动作覆盖。

+

影响:web/app/page.tsxdocs/source-analysis.html。后续同类处理面板应优先考虑“默认贴边,必要时切回画布”的交互。

+
+
+
+
+

2026-05-14 · 视频抽帧面板支持删除已抽关键帧

+ Canvas Panel + Frames +
+
+

问题:视频抽帧面板只能新增关键帧,用户看到“已抽关键帧”后不能在同一工作上下文里清理误抽的帧。

+

改动:已抽关键帧缩略图右上角增加删除按钮,点击后按 jobId + frameIdx 删除对应关键帧;删除期间显示小号 loading。图片类素材仍沿用快速删除策略,不弹确认。

+

影响:web/app/page.tsxweb/components/nodes/index.tsxdocs/source-analysis.html。视频抽帧面板不再依赖当前 active job 来决定删除目标。

+
+

2026-05-14 · 吸附工作面板贴近视口边缘

diff --git a/web/app/page.tsx b/web/app/page.tsx index fe79f65..6cae1d0 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -88,10 +88,10 @@ export default function Home() { const [selectedFrames, setSelectedFrames] = useState>(new Set()) const [expandedFrame, setExpandedFrame] = useState(null) const [framePanelScale, setFramePanelScale] = useState(1) - const [framePanelPinned, setFramePanelPinned] = useState(false) + const [framePanelPinned, setFramePanelPinned] = useState(true) const [videoPanelJobId, setVideoPanelJobId] = useState(null) const [videoPanelScale, setVideoPanelScale] = useState(1) - const [videoPanelDock, setVideoPanelDock] = useState("canvas") + const [videoPanelDock, setVideoPanelDock] = useState("left") const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0) const [storyboardFrame, setStoryboardFrame] = useState(null) const [workbenchOpen, setWorkbenchOpen] = useState(false) @@ -191,9 +191,10 @@ export default function Home() { 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))))) @@ -209,30 +210,38 @@ export default function Home() { }, []) const handleOpenFramePanel = useCallback((idx: number) => { + if (expandedFrame === null) setFramePanelPinned(true) setExpandedFrame(idx) - }, []) + }, [expandedFrame]) const handleFramePanelScaleChange = useCallback((scale: number) => { setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2))))) }, []) - const handleDeleteFrame = useCallback(async (idx: number) => { - if (!activeJobId) return + const handleDeleteFrameForJob = useCallback(async (jobId: string, idx: number) => { + const wasActive = activeJobId === jobId try { - const updated = await deleteFrame(activeJobId, idx) - setJob(updated) + 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() if (!prev.has(idx)) return prev const next = new Set(prev) next.delete(idx) return next }) - if (expandedFrame === idx) setExpandedFrame(null) + if (!wasActive || expandedFrame === idx) setExpandedFrame(null) toast.success(`分镜 ${idx + 1} 已删除`) } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } - }, [activeJobId, expandedFrame, setJob]) + }, [activeJobId, expandedFrame]) + + 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 @@ -512,6 +521,7 @@ export default function Home() { onJobUpdate: setJob as any, onDeleteJob: handleDeleteJob, onDeleteFrame: handleDeleteFrame, + onDeleteFrameForJob: handleDeleteFrameForJob, onDeleteGenerated: handleDeleteGenerated, onDeleteVideo: handleDeleteVideo, onDeleteCutout: handleDeleteCutout, @@ -523,7 +533,7 @@ export default function Home() { onCopyImage: handleCopyImage, pinnedNodes, onToggleNodePin: 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]) + }), [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, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const savedSizes = useMemo(() => loadNodeSizes(), []) @@ -681,7 +691,7 @@ export default function Home() { }, ] }) - if (shouldFocusNewPanel) { + if (shouldFocusNewPanel && !framePanelPinned) { window.setTimeout(() => { flowRef.current?.fitView?.({ nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "visual" }], diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 35aa44c..d51a68b 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -56,6 +56,7 @@ export interface NodeData { onDeleteJob?: (id: string) => void onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开 onDeleteFrame?: (idx: number) => void // 删整张关键帧 + onDeleteFrameForJob?: (jobId: string, idx: number) => Promise | void onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图 onDeleteVideo?: (videoId: string) => void // 删 Video Gen 任务 onDeleteCutout?: (frameIdx: number, elementId: string, cutoutId: string) => void // 删元素提取图 @@ -642,10 +643,12 @@ export function VideoFramePanelNode({ data }: any) { const docked = dock !== "canvas" const [currentT, setCurrentT] = useState(0) const [adding, setAdding] = useState(false) + const [deletingFrame, setDeletingFrame] = useState(null) useEffect(() => { setCurrentT(0) setAdding(false) + setDeletingFrame(null) }, [panelJob?.id]) useEffect(() => { @@ -714,6 +717,16 @@ export function VideoFramePanelNode({ data }: any) { } } + const deleteFrameFromPanel = async (idx: number) => { + setDeletingFrame(idx) + try { + if (d.onDeleteFrameForJob) await d.onDeleteFrameForJob(panelJob.id, idx) + else await d.onDeleteFrame?.(idx) + } finally { + setDeletingFrame(null) + } + } + const dockButtonClass = (value: CanvasPanelDock) => `nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${ dock === value @@ -823,18 +836,37 @@ export function VideoFramePanelNode({ data }: any) { {frames.length > 0 ? (
{frames.map((f) => ( - + + {(d.onDeleteFrameForJob || d.onDeleteFrame) && ( + + )} +
))}
) : (