diff --git a/.memory/worklog.json b/.memory/worklog.json
index 09201a4..adb88ef 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1649,6 +1649,19 @@
"message": "auto-save 2026-05-13 14:10 (~2)",
"hash": "6d87afa",
"files_changed": 2
+ },
+ {
+ "ts": "2026-05-13T14:16:19+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-13 14:16 (~1)",
+ "hash": "4983d9a",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-13T06:17:39Z",
+ "type": "session-heartbeat",
+ "message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 14:16 (~1)",
+ "files_changed": 2
}
]
}
diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx
index e72913c..b30fd98 100644
--- a/web/components/nodes/index.tsx
+++ b/web/components/nodes/index.tsx
@@ -1,5 +1,6 @@
"use client"
-import { useRef, useState } from "react"
+import { useEffect, useRef, useState } from "react"
+import { createPortal } from "react-dom"
import { type NodeProps } from "@xyflow/react"
import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
@@ -345,6 +346,9 @@ export function KeyframeNode({ data, selected }: any) {
const st = keyframeStatus(d.job)
const frames = d.job?.frames ?? []
const jobId = d.job?.id
+ const [hover, setHover] = useState<{ idx: number; rect: DOMRect } | null>(null)
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => setMounted(true), [])
return (
@@ -372,7 +376,9 @@ export function KeyframeNode({ data, selected }: any) {
>
{/* 删除按钮:hover 时右上角浮出 */}
{d.onDeleteFrame && (
@@ -486,6 +460,45 @@ export function KeyframeNode({ data, selected }: any) {
)}
+
+ {/* Portal hover 大预览 — 浮在缩略图上方(不挡其他界面) */}
+ {mounted && hover && jobId && (() => {
+ const hf = frames.find((x) => x.index === hover.idx)
+ if (!hf) return null
+ // 大图最大尺寸(按视频比例算)
+ const vidAspect = d.job && d.job.height > 0 ? d.job.height / d.job.width : 16 / 9
+ const maxH = Math.min(window.innerHeight * 0.7, hover.rect.top - 16)
+ const maxW = Math.min(window.innerWidth * 0.6, 600)
+ let h = maxH, w = h / vidAspect
+ if (w > maxW) { w = maxW; h = w * vidAspect }
+ // 水平居中到缩略图,clamp 在视口内
+ const centerX = hover.rect.left + hover.rect.width / 2
+ const left = Math.max(12, Math.min(window.innerWidth - w - 12, centerX - w / 2))
+ return createPortal(
+
+
+

+
+ 分镜 {hf.index + 1} · {hf.timestamp.toFixed(2)}s
+ 点击进入精细调整
+
+
+
,
+ document.body,
+ )
+ })()}
)
}
@@ -589,6 +602,9 @@ const IMAGEGEN_WIDTH = 360
export function ImageGenNode({ data, selected }: any) {
const d: NodeData = data
const job = d?.job
+ const [hover, setHover] = useState<{ key: string; rect: DOMRect } | null>(null)
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => setMounted(true), [])
// 上方浮条 = 所有 frame 的 elements crop("分镜头编排"的输入素材)
type ElPreview = { frameIdx: number; elementId: string; name: string }
@@ -612,55 +628,30 @@ export function ImageGenNode({ data, selected }: any) {
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
style={{ bottom: "calc(100% + 12px)" }}
>
- {elementCrops.map((p) => (
-
-
+
+ )
+ })}
)}
@@ -688,6 +679,41 @@ export function ImageGenNode({ data, selected }: any) {
)}
+
+ {/* Portal hover 大预览 — 视口居中 */}
+ {mounted && hoverKey && job && (() => {
+ const [fi, ei] = hoverKey.split("_")
+ const frameIdx = parseInt(fi, 10)
+ const p = elementCrops.find((x) => x.frameIdx === frameIdx && x.elementId === ei)
+ if (!p) return null
+ return createPortal(
+
+
+
+

+
+ {p.name}
+ 来自分镜 {p.frameIdx + 1} · 点击进入分镜头编排
+
+
+
,
+ document.body,
+ )
+ })()}
)
}