diff --git a/.memory/worklog.json b/.memory/worklog.json index 0d18724..49672a9 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -2665,6 +2665,19 @@ "message": "auto-save 2026-05-14 00:25 (+6, ~5)", "hash": "abeff42", "files_changed": 11 + }, + { + "ts": "2026-05-14T00:31:52+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 00:31 (~1)", + "hash": "5c9c80e", + "files_changed": 1 + }, + { + "ts": "2026-05-13T16:33:09Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 00:31 (~1)", + "files_changed": 1 } ] } diff --git a/web/components/nodes/hover-preview.tsx b/web/components/nodes/hover-preview.tsx new file mode 100644 index 0000000..852b618 --- /dev/null +++ b/web/components/nodes/hover-preview.tsx @@ -0,0 +1,57 @@ +"use client" + +/** + * 视觉类节点统一 hover 大预览: + * - 浮在缩略图正上方(bottom: calc(100% + 10px),居中),跟随 ReactFlow viewport 缩放 + * - 固定 280px 宽,原比例高 + * - 视频自动播放(muted loop),图片静态 + * - pointer-events-none,不阻挡同行/邻近交互 + * - 用法:
...
+ */ +interface Props { + imgSrc?: string + videoSrc?: string + poster?: string + aspect: string + label?: string + caption?: string + borderClass?: string + width?: number +} + +export function HoverPreview({ + imgSrc, videoSrc, poster, aspect, + label, caption, + borderClass = "border-violet-300/55", + width = 280, +}: Props) { + return ( +
+
+
+ {videoSrc ? ( +
+
+ ) +} diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index de75261..8697e6a 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react" import { toast } from "sonner" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" +import { HoverPreview } from "./hover-preview" import { type Job, type ImageRef, apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl, @@ -124,45 +125,54 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an {[...d.jobs].reverse().map((j) => { const isActive = j.id === d.activeJobId const ready = !!j.video_url + const aspectStr = ready ? `${j.width}/${j.height}` : "9/16" return ( - + {ready && ( + )} -
- {ready ? `${j.duration.toFixed(1)}s` : "…"} -
- +
) })} @@ -367,10 +377,10 @@ export function KeyframeNode({ data, selected }: any) { return (
- {/* 缩略图浮条(节点上方,最多 5 个一行,多行向上扩展) */} + {/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */} {frames.length > 0 && jobId && (
{frames.map((f) => { @@ -378,12 +388,13 @@ export function KeyframeNode({ data, selected }: any) { return (
0 ? `${d.job.width}/${d.job.height}` : "16/9", @@ -972,10 +983,10 @@ export function VideoGenNode({ data, selected }: any) {
{videos.length > 0 && (
- {videos.slice(0, 6).map((v, i) => { + {videos.map((v, i) => { const videoSrc = apiAssetUrl(v.url) const posterSrc = apiAssetUrl(v.poster_url) const ready = v.status === "completed" && !!videoSrc @@ -983,10 +994,10 @@ export function VideoGenNode({ data, selected }: any) { return (