diff --git a/.memory/worklog.json b/.memory/worklog.json index b2e34dc..ab4c670 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -2195,6 +2195,13 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 19:17 (~4)", "files_changed": 1 + }, + { + "ts": "2026-05-13T19:23:17+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 19:23 (~4)", + "hash": "1f9c094", + "files_changed": 4 } ] } diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 5eab037..dbf5e69 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -830,6 +830,39 @@ api/main.py

变更记录

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

+
+
+

2026-05-13 · 关键帧详情面板增加钉住按钮

+ KeyframePanelNode +
+
+

问题:面板可以拖动后,用户仍可能误拖;切换图片时希望保持固定工作位置。

+

改动:在标题栏增加钉子按钮。钉住后面板节点禁止拖动,切换关键帧只切换内容不移动位置;取消钉住后可继续拖动。

+

影响:web/app/page.tsxweb/components/nodes/index.tsx

+
+
+
+
+

2026-05-13 · 切换关键帧不再重置详情面板位置

+ KeyframePanelNode +
+
+

问题:用户把关键帧详情面板拖到合适位置后,再点击下一张关键帧会把面板拉回默认位置,造成视觉疲劳。

+

改动:已打开的面板只切换内容,不移动位置;只有面板不存在、首次打开时才放到默认位置并自动聚焦。

+

影响:web/app/page.tsx;关闭后重新打开仍会出现在默认位置。

+
+
+
+
+

2026-05-13 · 关键帧详情面板增加缩放控制

+ KeyframePanelNode +
+
+

问题:关键帧详情面板作为画布节点后可以随画布缩放,但面板自身没有尺寸控制,用户无法单独放大或缩小它。

+

改动:在面板标题栏增加 -、百分比重置、+ 控制,支持 75% 到 135% 的面板级缩放。

+

影响:web/app/page.tsxweb/components/nodes/index.tsx;点击新关键帧仍会找回到默认位置,缩放比例保留。

+
+

2026-05-13 · 关键帧详情从固定左侧抽屉迁到无限画布

diff --git a/web/app/page.tsx b/web/app/page.tsx index 5f321d4..12dabd0 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -67,7 +67,8 @@ export default function Home() { const [analyzing, setAnalyzing] = useState(false) const [selectedFrames, setSelectedFrames] = useState>(new Set()) const [expandedFrame, setExpandedFrame] = useState(null) - const [framePanelResetNonce, setFramePanelResetNonce] = useState(0) + const [framePanelScale, setFramePanelScale] = useState(1) + const [framePanelPinned, setFramePanelPinned] = useState(false) const [videoLightboxOpen, setVideoLightboxOpen] = useState(false) const [storyboardFrame, setStoryboardFrame] = useState(null) const [workbenchOpen, setWorkbenchOpen] = useState(false) @@ -170,7 +171,10 @@ export default function Home() { const handleOpenFramePanel = useCallback((idx: number) => { setExpandedFrame(idx) - setFramePanelResetNonce((n) => n + 1) + }, []) + + const handleFramePanelScaleChange = useCallback((scale: number) => { + setFramePanelScale(Math.max(0.75, Math.min(1.35, Number(scale.toFixed(2))))) }, []) const handleDeleteFrame = useCallback(async (idx: number) => { @@ -281,12 +285,16 @@ export default function Home() { analyzing, selectedFrames, expandedFrame, + framePanelScale, + framePanelPinned, onSubmitUrl: handleSubmit, onUploadFile: handleUpload, onAnalyze: handleAnalyze, onToggleFrame: handleToggleFrame, onExpandFrame: setExpandedFrame, onOpenFramePanel: handleOpenFramePanel, + onFramePanelScaleChange: handleFramePanelScaleChange, + onFramePanelPinnedChange: setFramePanelPinned, onCloseExpandedFrame: () => setExpandedFrame(null), onAddManualFrame: handleAddManualFrame, onOpenVideoLightbox: () => setVideoLightboxOpen(true), @@ -300,7 +308,7 @@ export default function Home() { setWorkbenchOpen(true) }, onCopyImage: handleCopyImage, - }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage]) + }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const [nodes, setNodes, onNodesChange] = useNodesState( @@ -324,16 +332,14 @@ export default function Home() { }, [nodeData, setNodes]) // 关键帧详情面板是独立 ReactFlow 节点:可拖动、跟随画布缩放。 - // 再次点击任意关键帧缩略图时,重新放回关键帧节点左侧附近,避免拖丢后找不到。 - const panelResetHandledRef = useRef(0) + // 已打开时点击其他关键帧只切换内容,不移动用户拖好的面板位置。 useEffect(() => { if (!job || expandedFrame === null) { setNodes((prev) => prev.filter((n) => n.id !== KEYFRAME_PANEL_ID)) return } - const shouldReset = panelResetHandledRef.current !== framePanelResetNonce - panelResetHandledRef.current = framePanelResetNonce + let shouldFocusNewPanel = false setNodes((prev) => { const keyframeNode = prev.find((n) => n.id === "keyframe") const inputNode = prev.find((n) => n.id === "input") @@ -347,13 +353,13 @@ export default function Home() { ? { ...n, data: nodeData, - position: shouldReset ? defaultPosition : n.position, - draggable: true, - dragHandle: ".keyframe-panel-drag", + draggable: !framePanelPinned, + dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag", } : n, ) } + shouldFocusNewPanel = true return [ ...prev, { @@ -361,13 +367,13 @@ export default function Home() { type: "keyframePanel", position: defaultPosition, data: nodeData, - draggable: true, - dragHandle: ".keyframe-panel-drag", + draggable: !framePanelPinned, + dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag", selectable: true, }, ] }) - if (shouldReset) { + if (shouldFocusNewPanel) { window.setTimeout(() => { flowRef.current?.fitView?.({ nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "keyframe" }], @@ -376,7 +382,7 @@ export default function Home() { }) }, 0) } - }, [job?.id, expandedFrame, framePanelResetNonce, nodeData, setNodes]) + }, [job?.id, expandedFrame, framePanelPinned, nodeData, setNodes]) // 边的 animated 状态跟 Job 进度联动 useEffect(() => { diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index ee5b52b..29c267d 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react" import { type NodeProps } from "@xyflow/react" import { Link2, Upload, Download, Scissors, Image as ImageIcon, - Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, + Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Pin, } from "lucide-react" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" import { type Job, type ImageRef, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl } from "@/lib/api" @@ -17,12 +17,16 @@ export interface NodeData { analyzing: boolean selectedFrames: Set expandedFrame: number | null + framePanelScale?: number + framePanelPinned?: boolean onSubmitUrl: (url: string) => void onUploadFile: (file: File) => void onAnalyze: () => void onToggleFrame: (idx: number) => void onExpandFrame: (idx: number) => void onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板 + onFramePanelScaleChange?: (scale: number) => void + onFramePanelPinnedChange?: (pinned: boolean) => void onCloseExpandedFrame: () => void onAddManualFrame: (t: number) => void onOpenVideoLightbox: () => void @@ -511,12 +515,21 @@ export function KeyframePanelNode({ data }: any) { const d: NodeData = data if (!d.job || d.expandedFrame === null) return null const active = d.job.frames.find((f) => f.index === d.expandedFrame) + const scale = d.framePanelScale ?? 1 + const pinned = d.framePanelPinned ?? false + const panelWidth = Math.round(760 * scale) + const panelHeight = Math.round(746 * scale) + const bodyHeight = Math.max(520, panelHeight - 27) + const setScale = (next: number) => { + const clamped = Math.max(0.75, Math.min(1.35, Number(next.toFixed(2)))) + d.onFramePanelScaleChange?.(clamped) + } return (
-
+
关键帧详情 · 元素提取 @@ -524,8 +537,46 @@ export function KeyframePanelNode({ data }: any) { {active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
-
- 拖动标题栏移动 · 再点缩略图可找回 +
+ + {pinned ? "已钉住 · 切图不移动" : "拖动标题栏移动 · 可钉住"} + + + + +
-
e.stopPropagation()}> +
e.stopPropagation()}>