diff --git a/.memory/worklog.json b/.memory/worklog.json
index 60852f9..d1bb45e 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -2953,6 +2953,19 @@
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 02:41 (~4)",
"files_changed": 1
+ },
+ {
+ "ts": "2026-05-14T02:47:36+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-14 02:47 (~1)",
+ "hash": "b5d8eb1",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-13T18:48:48Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 02:47 (~1)",
+ "files_changed": 3
}
]
}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index e041afa..645b04e 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -815,6 +815,18 @@ api/main.py
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-14 · 大图预览改为尺寸感知 Fit / 1:1
+ Canvas
+ HoverPreview
+
+
+
问题:有些源视频和图片分辨率很大,完全按原尺寸展示会撑爆画布;但统一固定预览大小又会丢失素材真实尺寸感知。
+
改动:HoverPreview 默认按原比例适应可视区域,角标显示原始分辨率和当前 Fit 百分比;点击钉住后可切换到 1:1,超大素材在预览框内滚动查看局部。
+
影响:web/components/nodes/hover-preview.tsx。视觉缩略图高度同步放大,图片缩略图的复制 / 删除按钮改为常驻大号 icon。
+
+
2026-05-14 · 缩略图滑动条改为大号可拖轨道
diff --git a/web/components/nodes/hover-preview.tsx b/web/components/nodes/hover-preview.tsx
index b3d8179..7ca0976 100644
--- a/web/components/nodes/hover-preview.tsx
+++ b/web/components/nodes/hover-preview.tsx
@@ -1,11 +1,13 @@
"use client"
-import { X } from "lucide-react"
+import { useEffect, useMemo, useRef, useState } from "react"
+import { Maximize2, Minimize2, X } from "lucide-react"
/**
* 视觉类节点统一大预览:
* - **在 ReactFlow 节点 DOM 内**作为 absolute 元素,底边贴当前缩略图上方边缘
* - 跟随 ReactFlow 画布 pan/zoom 一起变化(属于"无限画布"的一部分)
- * - 媒体按"自然像素分辨率"渲染,不做 max 尺寸限制
+ * - 默认按原比例 fit 到可视区域,并标注原始分辨率 / 当前缩放比例
+ * - pinned 后可切换到 1:1;大图在预览框内滚动查看局部
* - 不 pinned 时:pointer-events-none,依赖调用方传入 visible
* - pinned=true:强制 visible,pointer-events 开启,可点 × 关闭
* - 用法:渲染在节点根层,不要放进 overflow-x-auto 缩略图滚动条里
@@ -36,9 +38,78 @@ export function HoverPreview({
onClose,
}: Props) {
const shown = pinned || visible
+ const imgRef = useRef(null)
+ const videoRef = useRef(null)
+ const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
+ const [actualSize, setActualSize] = useState(false)
+ const [viewport, setViewport] = useState({ width: 1200, height: 800 })
+
+ useEffect(() => {
+ setNaturalSize(null)
+ setActualSize(false)
+ }, [imgSrc, videoSrc])
+
+ useEffect(() => {
+ if (!pinned) setActualSize(false)
+ }, [pinned])
+
+ useEffect(() => {
+ const update = () => setViewport({
+ width: window.innerWidth || 1200,
+ height: window.innerHeight || 800,
+ })
+ update()
+ window.addEventListener("resize", update)
+ return () => window.removeEventListener("resize", update)
+ }, [])
+
+ useEffect(() => {
+ if (!shown) return
+ const raf = window.requestAnimationFrame(() => {
+ const img = imgRef.current
+ if (img?.complete && img.naturalWidth && img.naturalHeight) {
+ setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight })
+ return
+ }
+ const video = videoRef.current
+ if (video?.videoWidth && video.videoHeight) {
+ setNaturalSize({ width: video.videoWidth, height: video.videoHeight })
+ }
+ })
+ return () => window.cancelAnimationFrame(raf)
+ }, [imgSrc, shown, videoSrc])
+
+ const fitScale = useMemo(() => {
+ if (!naturalSize) return null
+ const maxWidth = Math.min(920, viewport.width * 0.7)
+ const maxHeight = Math.min(820, viewport.height * 0.72)
+ return Math.min(1, maxWidth / naturalSize.width, maxHeight / naturalSize.height)
+ }, [naturalSize, viewport.height, viewport.width])
+
+ const metadataText = naturalSize
+ ? `原始 ${naturalSize.width}×${naturalSize.height} · ${actualSize ? "1:1" : `Fit ${Math.round((fitScale ?? 1) * 100)}%`}`
+ : actualSize ? "1:1" : "Fit"
+
const visibilityCls = shown
? pinned ? "opacity-100 pointer-events-auto" : "opacity-100 pointer-events-none"
: "pointer-events-none opacity-0"
+ const previewFrameStyle = actualSize
+ ? {
+ width: "max-content",
+ maxWidth: "min(78vw, 1040px)",
+ maxHeight: "min(76vh, 860px)",
+ overflow: "auto",
+ }
+ : {
+ width: "max-content",
+ maxWidth: "min(70vw, 920px)",
+ maxHeight: "min(72vh, 820px)",
+ overflow: "hidden",
+ }
+ const mediaStyle = actualSize
+ ? { width: "auto", height: "auto", maxWidth: "none", maxHeight: "none" }
+ : { width: "auto", height: "auto", maxWidth: "min(70vw, 920px)", maxHeight: "min(72vh, 820px)" }
+
return (
+ style={previewFrameStyle}>
+ {shown && (
+
+ {metadataText}
+
+ )}
{videoSrc ? (
)}
{pinned && onClose && (
-
+
+
+
+
)}
diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx
index 4fc4a4d..f4be8c9 100644
--- a/web/components/nodes/index.tsx
+++ b/web/components/nodes/index.tsx
@@ -104,6 +104,8 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
+const THUMBNAIL_HEIGHT = 176
+
function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) {
if (!root) return { x: 160, y: 0 }
const rootRect = root.getBoundingClientRect()
@@ -356,7 +358,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
onClick={(e) => { e.stopPropagation(); fileRef.current?.click() }}
title="再上传一个视频"
className="shrink-0 rounded-md border border-dashed border-white/30 hover:border-white/50 bg-white/[0.04] hover:bg-white/[0.08] inline-flex items-center justify-center text-white/60 hover:text-white transition"
- style={{ width: 88, height: 160 }}
+ style={{ width: 96, height: THUMBNAIL_HEIGHT }}
>
@@ -370,7 +372,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
className={`group relative shrink-0 rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
}`}
- style={{ height: 160, aspectRatio: aspectStr }}
+ style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewJob(null)}
>
@@ -765,7 +767,7 @@ export function VisualLabNode({ data, selected }: any) {
? isSelected ? "border-emerald-400 ring-2 ring-emerald-400/60" : "border-white/30 dark:border-white/20"
: p.borderClass
} ${p.kind === "cutout" ? "bg-white" : "bg-black"}`}
- style={{ height: 150, aspectRatio: aspect }}
+ style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
onMouseEnter={(e) => setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreview(null)}
>
@@ -819,9 +821,9 @@ export function VisualLabNode({ data, selected }: any) {
d.onCopyImage?.({ kind: "keyframe", frame_idx: p.frameIdx, label: `${p.label} 关键帧` })
}}
title="复制此图(到分镜头编排工作台插槽粘贴)"
- className="absolute top-1 left-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center text-[10px] leading-none transition hover:bg-violet-400 hover:scale-110"
+ className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
>
- 📋
+
)}
@@ -839,9 +841,9 @@ export function VisualLabNode({ data, selected }: any) {
})
}}
title="复制此图(到分镜头编排工作台插槽粘贴)"
- className="absolute top-1 left-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center text-[10px] leading-none transition hover:bg-violet-400 hover:scale-110"
+ className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
>
- 📋
+
)}
@@ -855,10 +857,10 @@ export function VisualLabNode({ data, selected }: any) {
void navigator.clipboard?.writeText(video.prompt).catch(() => {})
toast.success("已复制视频 prompt")
}}
- className="absolute left-1 top-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-violet-400 hover:scale-110"
+ className="absolute left-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
title="复制视频 prompt"
>
-
+
)}
@@ -870,9 +872,9 @@ export function VisualLabNode({ data, selected }: any) {
if (confirm(`删除${p.label}?相关清洗 / 抠图 / 生成图都会一并清除。`)) d.onDeleteFrame?.(p.frameIdx)
}}
title="删除该关键帧"
- className="absolute top-1 right-1 z-[70] h-5 w-5 rounded-full bg-black/70 text-white/80 backdrop-blur inline-flex items-center justify-center opacity-0 transition hover:bg-rose-500 hover:text-white group-hover:opacity-100"
+ className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
>
-
+
)}
@@ -886,9 +888,9 @@ export function VisualLabNode({ data, selected }: any) {
}
}}
title="删除该提取图"
- className="absolute top-1 right-1 z-[70] h-5 w-5 rounded-full bg-black/70 text-white/85 backdrop-blur inline-flex items-center justify-center opacity-0 transition hover:bg-rose-500 hover:text-white group-hover:opacity-100"
+ className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
>
-
+
)}
@@ -899,10 +901,10 @@ export function VisualLabNode({ data, selected }: any) {
e.stopPropagation()
d.onDeleteVideo?.(p.videoId)
}}
- className="absolute right-1 top-1 z-[70] h-5 w-5 rounded-full bg-rose-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-rose-400 hover:scale-110"
+ className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
title="删除这个视频任务"
>
-
+
)}
@@ -1022,7 +1024,7 @@ export function KeyframeNode({ data, selected }: any) {
: "border-white/30 dark:border-white/20"
}`}
style={{
- height: 160,
+ height: THUMBNAIL_HEIGHT,
aspectRatio: d.job && d.job.height > 0
? `${d.job.width}/${d.job.height}`
: "16/9",
@@ -1078,13 +1080,13 @@ export function KeyframeNode({ data, selected }: any) {
label: `分镜 ${f.index + 1} 关键帧`,
})
}}
- title="📋 复制此图(到分镜头编排工作台插槽粘贴)"
- className="absolute top-1 left-1 h-5 w-5 rounded-full bg-violet-500/90 backdrop-blur text-white shadow-md hover:bg-violet-400 hover:scale-110 inline-flex items-center justify-center transition z-[70] text-[10px] leading-none"
+ title="复制此图(到分镜头编排工作台插槽粘贴)"
+ className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
>
- 📋
+
)}
- {/* 删除按钮:hover 时右上角浮出 */}
+ {/* 删除按钮:常驻可见 */}
{d.onDeleteFrame && (
)}
@@ -1544,7 +1546,7 @@ export function StoryboardNode({ data, selected }: any) {
setHoverPreviewCutout({ id: key, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewCutout(null)}
>
@@ -1579,13 +1581,13 @@ export function StoryboardNode({ data, selected }: any) {
label: p.name,
})
}}
- title="📋 复制此图(到分镜头编排工作台插槽粘贴)"
- className="absolute top-1 left-1 h-5 w-5 rounded-full bg-violet-500/90 backdrop-blur text-white shadow-md hover:bg-violet-400 hover:scale-110 inline-flex items-center justify-center transition z-[70] text-[10px] leading-none"
+ title="复制此图(到分镜头编排工作台插槽粘贴)"
+ className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
>
- 📋
+
)}
- {/* 右上:删除(hover 浮出) */}
+ {/* 右上:删除 */}
{d.onDeleteCutout && (
)}
@@ -1695,7 +1697,7 @@ export function VideoGenNode({ data, selected }: any) {
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-black ${
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
}`}
- style={{ height: 160, aspectRatio: aspect }}
+ style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
onMouseEnter={(e) => setHoverPreviewVideo({ id: v.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewVideo(null)}
>
@@ -1747,10 +1749,10 @@ export function VideoGenNode({ data, selected }: any) {
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
toast.success("已复制视频 prompt")
}}
- className="absolute left-1 top-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-violet-400 hover:scale-110"
+ className="absolute left-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
title="复制视频 prompt"
>
-
+
)})}