auto-save 2026-05-13 19:23 (~4)

This commit is contained in:
2026-05-13 19:23:17 +08:00
parent fda298082a
commit 1f9c0947a8
4 changed files with 139 additions and 33 deletions

View File

@@ -2182,6 +2182,19 @@
"message": "auto-save 2026-05-13 19:12 (~3)",
"hash": "61a4bec",
"files_changed": 3
},
{
"ts": "2026-05-13T19:17:48+08:00",
"type": "commit",
"message": "auto-save 2026-05-13 19:17 (~4)",
"hash": "fda2980",
"files_changed": 4
},
{
"ts": "2026-05-13T11:19:29Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-13 19:17 (~4)",
"files_changed": 1
}
]
}

View File

@@ -837,8 +837,8 @@ api/main.py
</header>
<div class="body">
<p><strong>问题:</strong>关键帧详情 / 元素提取面板固定在左侧 drawer和 ReactFlow 无限画布割裂,也不会跟随画布缩放。</p>
<p><strong>改动:</strong>移除主页面隐藏渲染的 <code>Dashboard</code> drawer 承载方式,改为在 <code>KeyframeNode</code> 内部挂载 <code>FrameLightbox</code>,作为画布上的工作面板</p>
<p><strong>影响:</strong><code>web/app/page.tsx</code><code>web/components/nodes/index.tsx</code>;点关键帧后面板会跟随 ReactFlow 平移和缩放</p>
<p><strong>改动:</strong>移除主页面隐藏渲染的 <code>Dashboard</code> drawer 承载方式,新增独立 <code>keyframePanel</code> ReactFlow 节点来挂载 <code>FrameLightbox</code></p>
<p><strong>影响:</strong><code>web/app/page.tsx</code><code>web/components/nodes/index.tsx</code>;点关键帧后面板默认出现在流程左侧空白画布里,不遮挡 Input / Keyframe 主节点;标题栏可拖动,跟随 ReactFlow 平移和缩放。再次点击关键帧缩略图会把面板找回到默认位置,并自动把视野拉到“关键帧 + 面板”</p>
</div>
</article>
<article class="change">

View File

@@ -9,7 +9,7 @@ import {
import { Toaster, toast } from "sonner"
import {
InputNode, KeyframeNode, ASRNode,
TranslateNode, RewriteNode, StoryboardNode, VideoGenNode, ComposeNode,
TranslateNode, RewriteNode, StoryboardNode, VideoGenNode, ComposeNode, KeyframePanelNode,
type NodeData,
} from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle"
@@ -27,8 +27,11 @@ const NODE_TYPES = {
storyboard: StoryboardNode,
videogen: VideoGenNode,
compose: ComposeNode,
keyframePanel: KeyframePanelNode,
}
const KEYFRAME_PANEL_ID = "keyframe-detail-panel"
// 合并 input + download + split 为一个节点
// 分叉:上路 input → keyframe → storyboard → videogen ↘
// 下路 input → asr → translate → rewrite ──────→ storyboard / compose
@@ -64,10 +67,12 @@ export default function Home() {
const [analyzing, setAnalyzing] = useState(false)
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
const [framePanelResetNonce, setFramePanelResetNonce] = useState(0)
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
const [workbenchOpen, setWorkbenchOpen] = useState(false)
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
const flowRef = useRef<any>(null)
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => {
@@ -163,6 +168,11 @@ export default function Home() {
})
}, [])
const handleOpenFramePanel = useCallback((idx: number) => {
setExpandedFrame(idx)
setFramePanelResetNonce((n) => n + 1)
}, [])
const handleDeleteFrame = useCallback(async (idx: number) => {
if (!activeJobId) return
try {
@@ -276,6 +286,7 @@ export default function Home() {
onAnalyze: handleAnalyze,
onToggleFrame: handleToggleFrame,
onExpandFrame: setExpandedFrame,
onOpenFramePanel: handleOpenFramePanel,
onCloseExpandedFrame: () => setExpandedFrame(null),
onAddManualFrame: handleAddManualFrame,
onOpenVideoLightbox: () => setVideoLightboxOpen(true),
@@ -289,7 +300,7 @@ export default function Home() {
setWorkbenchOpen(true)
},
onCopyImage: handleCopyImage,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
@@ -312,6 +323,61 @@ export default function Home() {
setNodes((prev) => prev.map((n) => ({ ...n, data: nodeData })))
}, [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
setNodes((prev) => {
const keyframeNode = prev.find((n) => n.id === "keyframe")
const inputNode = prev.find((n) => n.id === "input")
const defaultPosition = {
x: (inputNode?.position.x ?? 40) - 820,
y: (keyframeNode?.position.y ?? 60),
}
const exists = prev.some((n) => n.id === KEYFRAME_PANEL_ID)
if (exists) {
return prev.map((n) => n.id === KEYFRAME_PANEL_ID
? {
...n,
data: nodeData,
position: shouldReset ? defaultPosition : n.position,
draggable: true,
dragHandle: ".keyframe-panel-drag",
}
: n,
)
}
return [
...prev,
{
id: KEYFRAME_PANEL_ID,
type: "keyframePanel",
position: defaultPosition,
data: nodeData,
draggable: true,
dragHandle: ".keyframe-panel-drag",
selectable: true,
},
]
})
if (shouldReset) {
window.setTimeout(() => {
flowRef.current?.fitView?.({
nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "keyframe" }],
padding: 0.18,
duration: 260,
})
}, 0)
}
}, [job?.id, expandedFrame, framePanelResetNonce, nodeData, setNodes])
// 边的 animated 状态跟 Job 进度联动
useEffect(() => {
const doneOf: Record<string, boolean> = {
@@ -351,6 +417,7 @@ export default function Home() {
<ReactFlow
nodes={nodes}
edges={edges}
onInit={(instance) => { flowRef.current = instance }}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}

View File

@@ -22,6 +22,7 @@ export interface NodeData {
onAnalyze: () => void
onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => void
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
onCloseExpandedFrame: () => void
onAddManualFrame: (t: number) => void
onOpenVideoLightbox: () => void
@@ -375,8 +376,11 @@ export function KeyframeNode({ data, selected }: any) {
}}
>
<button
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(f.index) }}
title={`${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · hover 看大图 · 点击精细调整`}
onClick={(e) => {
e.stopPropagation()
;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index)
}}
title={`${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · hover 看大图 · 点击打开 / 找回详情面板`}
className="absolute inset-0 w-full h-full"
>
<img
@@ -496,34 +500,56 @@ export function KeyframeNode({ data, selected }: any) {
)}
</NodeShell>
{/* 关键帧详情 / 元素提取:作为无限画布上的工作面板,跟随 ReactFlow 缩放和平移 */}
{d.job && d.expandedFrame !== null && (
<div
className="nodrag nowheel absolute z-[120]"
style={{
left: 0,
top: "calc(100% + 18px)",
width: 760,
height: 720,
}}
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
<FrameLightbox
embedded
jobId={d.job.id}
frames={d.job.frames}
activeIndex={d.expandedFrame}
selected={d.selectedFrames}
onClose={d.onCloseExpandedFrame}
onChange={d.onExpandFrame}
onToggleSelect={d.onToggleFrame}
onJobUpdate={d.onJobUpdate}
onCopyImage={d.onCopyImage}
/>
</div>
)}
</div>
)
}
/* ============================================================
4b. KeyframePanelNode — 画布内可移动详情面板
============================================================ */
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)
return (
<div
className="rounded-2xl border border-white/15 bg-black/70 shadow-2xl overflow-hidden"
style={{ width: 760, height: 746, boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)" }}
>
<div className="keyframe-panel-drag flex h-7 cursor-move items-center justify-between bg-gradient-to-r from-orange-500 to-red-500 px-3 text-white">
<div className="flex min-w-0 items-center gap-2">
<ImageIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate text-[12px] font-semibold"> · </span>
<span className="shrink-0 text-[10px] font-mono text-white/65">
{active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-white/60"> · </span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
className="nodrag h-5 w-5 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
title="关闭"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
<div className="nodrag nowheel h-[719px]" onWheel={(e) => e.stopPropagation()}>
<FrameLightbox
embedded
jobId={d.job.id}
frames={d.job.frames}
activeIndex={d.expandedFrame}
selected={d.selectedFrames}
onClose={d.onCloseExpandedFrame}
onChange={d.onExpandFrame}
onToggleSelect={d.onToggleFrame}
onJobUpdate={d.onJobUpdate}
onCopyImage={d.onCopyImage}
/>
</div>
</div>
)
}