auto-save 2026-05-14 01:57 (+1, ~4)

This commit is contained in:
2026-05-14 01:57:34 +08:00
parent 82a721b5ac
commit 11de581068
5 changed files with 2168 additions and 2082 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
- generic [active] [ref=e1]:
- main [ref=e3]:
- button "自动排版 · 保留每个节点的尺寸,重新排好间距和列布局" [ref=e5]:
- img [ref=e6]
- application [ref=e13]:
- img
- generic "Control Panel" [ref=e16]:
- button "Zoom In" [ref=e17] [cursor=pointer]:
- img [ref=e18]
- button "Zoom Out" [ref=e20] [cursor=pointer]:
- img [ref=e21]
- button "Fit View" [ref=e23] [cursor=pointer]:
- img [ref=e24]
- button "Toggle Interactivity" [ref=e26] [cursor=pointer]:
- img [ref=e27]
- img "Mini Map" [ref=e30]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e37] [cursor=pointer]:
- img [ref=e38]

View File

@@ -83,7 +83,6 @@ const EDGES_RAW: Array<[string, string]> = [
["asr", "translate"], ["asr", "translate"],
["translate", "rewrite"], ["translate", "rewrite"],
["keyframe", "storyboard"], ["keyframe", "storyboard"],
["rewrite", "storyboard"],
["storyboard", "videogen"], ["storyboard", "videogen"],
["videogen", "compose"], ["videogen", "compose"],
["rewrite", "compose"], ["rewrite", "compose"],

View File

@@ -3,21 +3,23 @@ import { X } from "lucide-react"
/** /**
* 视觉类节点统一大预览: * 视觉类节点统一大预览:
* - **在 ReactFlow 节点 DOM 内**作为 absolute 元素,贴 thumb 上方边缘bottom: calc(100% + 10px) + 居中) * - **在 ReactFlow 节点 DOM 内**作为 absolute 元素,贴节点卡片上边缘
* - 跟随 ReactFlow 画布 pan/zoom 一起变化(属于"无限画布"的一部分) * - 跟随 ReactFlow 画布 pan/zoom 一起变化(属于"无限画布"的一部分)
* - 媒体按"自然像素分辨率"渲染 + max-w/max-h 限制,避免占满整个画布 * - 媒体按"自然像素分辨率"渲染,不做 max 尺寸限制
* - 不 pinned 时pointer-events-none依赖 group-hover 显示/隐藏 * - 不 pinned 时pointer-events-none依赖调用方传入 visible
* - pinned=true强制 visiblepointer-events 开启,可点 × 关闭 * - pinned=true强制 visiblepointer-events 开启,可点 × 关闭
* - 用法:父级容器要带 `group` classHoverPreview 直接作为子元素 * - 用法:渲染在节点根层,不要放进 overflow-x-auto 缩略图滚动条里
*/ */
interface Props { interface Props {
imgSrc?: string imgSrc?: string
videoSrc?: string videoSrc?: string
poster?: string poster?: string
aspect: string aspect?: string
label?: string label?: string
caption?: string caption?: string
borderClass?: string borderClass?: string
visible?: boolean
anchorX?: number
pinned?: boolean pinned?: boolean
onClose?: () => void onClose?: () => void
} }
@@ -26,19 +28,22 @@ export function HoverPreview({
imgSrc, videoSrc, poster, aspect, imgSrc, videoSrc, poster, aspect,
label, caption, label, caption,
borderClass = "border-violet-300/55", borderClass = "border-violet-300/55",
visible = false,
anchorX,
pinned = false, pinned = false,
onClose, onClose,
}: Props) { }: Props) {
const visibilityCls = pinned const shown = pinned || visible
? "opacity-100 scale-100 pointer-events-auto" const visibilityCls = shown
: "pointer-events-none opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100" ? pinned ? "opacity-100 pointer-events-auto" : "opacity-100 pointer-events-none"
: "pointer-events-none opacity-0"
return ( return (
<div <div
className={`absolute transition-all duration-150 z-[60] ${visibilityCls}`} className={`absolute transition-all duration-150 z-[120] ${visibilityCls}`}
style={{ style={{
bottom: "calc(100% + 10px)", bottom: "calc(100% + 8px)",
left: "50%", left: typeof anchorX === "number" ? `${anchorX}px` : "50%",
transform: "translateX(-50%)", transform: `translateX(-50%) scale(${shown ? 1 : 0.96})`,
transformOrigin: "bottom center", transformOrigin: "bottom center",
}} }}
> >
@@ -54,14 +59,19 @@ export function HoverPreview({
autoPlay autoPlay
preload="auto" preload="auto"
className="block" className="block"
style={{ maxWidth: "none" }} style={{ width: "auto", height: "auto", maxWidth: "none", maxHeight: "none" }}
onLoadedMetadata={(e) => { e.currentTarget.play().catch(() => {}) }} onLoadedMetadata={(e) => { e.currentTarget.play().catch(() => {}) }}
onCanPlay={(e) => { e.currentTarget.play().catch(() => {}) }} onCanPlay={(e) => { e.currentTarget.play().catch(() => {}) }}
/> />
) : imgSrc ? ( ) : imgSrc ? (
<img src={imgSrc} alt="" className="block" style={{ maxWidth: "none" }} /> <img
src={imgSrc}
alt=""
className="block"
style={{ width: "auto", height: "auto", maxWidth: "none", maxHeight: "none" }}
/>
) : ( ) : (
<div className="w-40 h-40 bg-black/40" /> <div className="w-40 bg-black/40" style={{ aspectRatio: aspect ?? "1/1" }} />
)} )}
{(label || caption) && ( {(label || caption) && (
<div className="px-2 py-1 bg-black/80 text-white text-[11px] flex items-center justify-between"> <div className="px-2 py-1 bg-black/80 text-white text-[11px] flex items-center justify-between">

View File

@@ -85,6 +85,17 @@ function asrStatus(job: Job | null): NodeStatus {
return "pending" return "pending"
} }
type PreviewAnchor<T extends string | number> = { id: T; x: number }
function canvasAnchorX(root: HTMLDivElement | null, target: HTMLElement) {
if (!root) return 160
const rootRect = root.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
if (rootRect.width <= 0) return root.clientWidth / 2
const ratio = (targetRect.left + targetRect.width / 2 - rootRect.left) / rootRect.width
return ratio * root.clientWidth
}
/* ============================================================ /* ============================================================
1. InputNode — TK 链接 / 上传 1. InputNode — TK 链接 / 上传
============================================================ */ ============================================================ */
@@ -94,7 +105,9 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const [videoT, setVideoT] = useState(0) const [videoT, setVideoT] = useState(0)
const [addingFrame, setAddingFrame] = useState(false) const [addingFrame, setAddingFrame] = useState(false)
const [videoExpanded, setVideoExpanded] = useState(false) const [videoExpanded, setVideoExpanded] = useState(false)
const [pinnedPreviewJob, setPinnedPreviewJob] = useState<string | null>(null) const [hoverPreviewJob, setHoverPreviewJob] = useState<PreviewAnchor<string> | null>(null)
const [pinnedPreviewJob, setPinnedPreviewJob] = useState<PreviewAnchor<string> | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const job = d.job const job = d.job
@@ -120,7 +133,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const inputLocked = isDownloading || d.submitting const inputLocked = isDownloading || d.submitting
return ( return (
<div className="relative" style={{ width: "100%", height: "100%" }}> <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{/* 多视频缩略图浮条 — 「+」在最左job 按时间倒序(最新靠左高亮),统一高度 64宽度按视频原比例一行横滚。 {/* 多视频缩略图浮条 — 「+」在最左job 按时间倒序(最新靠左高亮),统一高度 64宽度按视频原比例一行横滚。
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */} 浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
{!videoExpanded && d.jobs.length > 0 && ( {!videoExpanded && d.jobs.length > 0 && (
@@ -149,13 +162,16 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25" isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
}`} }`}
style={{ height: 160, aspectRatio: aspectStr }} style={{ height: 160, aspectRatio: aspectStr }}
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, x: canvasAnchorX(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewJob(null)}
> >
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
// 单击:钉住 / 取消钉住大预览 + 切换 active若需要 // 单击:钉住 / 取消钉住大预览 + 切换 active若需要
setPinnedPreviewJob((prev) => (prev === j.id ? null : j.id)) const x = canvasAnchorX(rootRef.current, e.currentTarget)
setPinnedPreviewJob((prev) => (prev?.id === j.id ? null : { id: j.id, x }))
if (!isActive && ready) d.onSwitchJob(j.id) if (!isActive && ready) d.onSwitchJob(j.id)
}} }}
onDoubleClick={(e) => { onDoubleClick={(e) => {
@@ -184,23 +200,33 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
{ready ? `${j.duration.toFixed(1)}s` : "…"} {ready ? `${j.duration.toFixed(1)}s` : "…"}
</div> </div>
</button> </button>
{ready && (
<HoverPreview
videoSrc={videoUrl(j.id)}
aspect={aspectStr}
label={`${j.width}×${j.height}`}
caption={`${j.duration.toFixed(1)}s`}
borderClass="border-violet-300/60"
pinned={pinnedPreviewJob === j.id}
onClose={() => setPinnedPreviewJob(null)}
/>
)}
</div> </div>
) )
})} })}
</div> </div>
)} )}
{(() => {
const anchor = pinnedPreviewJob ?? hoverPreviewJob
if (!anchor || videoExpanded) return null
const previewJob = d.jobs.find((j) => j.id === anchor.id)
if (!previewJob?.video_url) return null
const aspectStr = previewJob.height ? `${previewJob.width}/${previewJob.height}` : "9/16"
return (
<HoverPreview
videoSrc={videoUrl(previewJob.id)}
aspect={aspectStr}
label={previewJob.width && previewJob.height ? `${previewJob.width}×${previewJob.height}` : "原视频"}
caption={previewJob.duration ? `${previewJob.duration.toFixed(1)}s` : undefined}
borderClass="border-violet-300/60"
visible={!!hoverPreviewJob && !pinnedPreviewJob}
anchorX={anchor.x}
pinned={!!pinnedPreviewJob}
onClose={() => setPinnedPreviewJob(null)}
/>
)
})()}
{/* 展开态 — 稍微放大360 宽),含 controls + 加帧按钮,不全屏 */} {/* 展开态 — 稍微放大360 宽),含 controls + 加帧按钮,不全屏 */}
{hasVideo && job && videoExpanded && ( {hasVideo && job && videoExpanded && (
<div <div
@@ -399,7 +425,9 @@ export function KeyframeNode({ data, selected }: any) {
const frames = d.job?.frames ?? [] const frames = d.job?.frames ?? []
const jobId = d.job?.id const jobId = d.job?.id
const aspectStr = d.job && d.job.height > 0 ? `${d.job.width}/${d.job.height}` : "9/16" const aspectStr = d.job && d.job.height > 0 ? `${d.job.width}/${d.job.height}` : "9/16"
const [pinnedPreviewFrame, setPinnedPreviewFrame] = useState<number | null>(null) const [hoverPreviewFrame, setHoverPreviewFrame] = useState<PreviewAnchor<number> | null>(null)
const [pinnedPreviewFrame, setPinnedPreviewFrame] = useState<PreviewAnchor<number> | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
// 点击 keyframe 节点外的任何位置 → 取消 pincapture 阶段,避免 ReactFlow pane 拦截) // 点击 keyframe 节点外的任何位置 → 取消 pincapture 阶段,避免 ReactFlow pane 拦截)
useEffect(() => { useEffect(() => {
@@ -414,7 +442,7 @@ export function KeyframeNode({ data, selected }: any) {
}, [pinnedPreviewFrame]) }, [pinnedPreviewFrame])
return ( return (
<div className="relative" style={{ width: "100%", height: "100%" }}> <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */} {/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */}
{frames.length > 0 && jobId && ( {frames.length > 0 && jobId && (
<div <div
@@ -437,11 +465,14 @@ export function KeyframeNode({ data, selected }: any) {
? `${d.job.width}/${d.job.height}` ? `${d.job.width}/${d.job.height}`
: "16/9", : "16/9",
}} }}
onMouseEnter={(e) => setHoverPreviewFrame({ id: f.index, x: canvasAnchorX(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewFrame(null)}
> >
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setPinnedPreviewFrame((prev) => (prev === f.index ? null : f.index)) const x = canvasAnchorX(rootRef.current, e.currentTarget)
setPinnedPreviewFrame((prev) => (prev?.id === f.index ? null : { id: f.index, x }))
;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index) ;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index)
}} }}
title={`${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 单击钉住大预览 / 打开详情面板`} title={`${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 单击钉住大预览 / 打开详情面板`}
@@ -506,21 +537,32 @@ export function KeyframeNode({ data, selected }: any) {
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
)} )}
<HoverPreview
imgSrc={effectiveFrameUrl(jobId, f)}
aspect={aspectStr}
label={`分镜 ${f.index + 1}`}
caption={`${f.timestamp.toFixed(2)}s`}
borderClass="border-orange-300/50"
pinned={pinnedPreviewFrame === f.index}
onClose={() => setPinnedPreviewFrame(null)}
/>
</div> </div>
) )
})} })}
</div> </div>
)} )}
{(() => {
const anchor = pinnedPreviewFrame ?? hoverPreviewFrame
if (!anchor || !jobId) return null
const frame = frames.find((f) => f.index === anchor.id)
if (!frame) return null
return (
<HoverPreview
imgSrc={effectiveFrameUrl(jobId, frame)}
aspect={aspectStr}
label={`分镜 ${frame.index + 1}`}
caption={`${frame.timestamp.toFixed(2)}s`}
borderClass="border-orange-300/50"
visible={!!hoverPreviewFrame && !pinnedPreviewFrame}
anchorX={anchor.x}
pinned={!!pinnedPreviewFrame}
onClose={() => setPinnedPreviewFrame(null)}
/>
)
})()}
<NodeShell <NodeShell
type="process" status={st} type="process" status={st}
icon={<ImageIcon className="h-4 w-4" />} icon={<ImageIcon className="h-4 w-4" />}
@@ -850,7 +892,9 @@ const IMAGEGEN_WIDTH = 360
export function StoryboardNode({ data, selected }: any) { export function StoryboardNode({ data, selected }: any) {
const d: NodeData = data const d: NodeData = data
const job = d?.job const job = d?.job
const [pinnedPreviewCutout, setPinnedPreviewCutout] = useState<string | null>(null) const [hoverPreviewCutout, setHoverPreviewCutout] = useState<PreviewAnchor<string> | null>(null)
const [pinnedPreviewCutout, setPinnedPreviewCutout] = useState<PreviewAnchor<string> | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
// 点击 storyboard 节点外 → 取消 pin // 点击 storyboard 节点外 → 取消 pin
useEffect(() => { useEffect(() => {
@@ -865,7 +909,7 @@ export function StoryboardNode({ data, selected }: any) {
}, [pinnedPreviewCutout]) }, [pinnedPreviewCutout])
// 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材) // 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材)
type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; frameSrc: string; timestamp: number } type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number }
const elementCrops: ElPreview[] = job const elementCrops: ElPreview[] = job
? job.frames.flatMap((f) => ? job.frames.flatMap((f) =>
(f.elements ?? []) (f.elements ?? [])
@@ -881,7 +925,6 @@ export function StoryboardNode({ data, selected }: any) {
name: e.name_zh, name: e.name_zh,
src, src,
cid, cid,
frameSrc: effectiveFrameUrl(job.id, f),
timestamp: f.timestamp, timestamp: f.timestamp,
} }
}) })
@@ -895,7 +938,7 @@ export function StoryboardNode({ data, selected }: any) {
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16" const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
return ( return (
<div className="relative" style={{ width: "100%", height: "100%" }}> <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{/* 节点上方:所有元素 crop 图(编排输入素材)— 视觉类节点统一样板:单行横滚 + 左上复制 + 右上删除 + hover/click pin 大预览 */} {/* 节点上方:所有元素 crop 图(编排输入素材)— 视觉类节点统一样板:单行横滚 + 左上复制 + 右上删除 + hover/click pin 大预览 */}
{elementCrops.length > 0 && job && ( {elementCrops.length > 0 && job && (
<div <div
@@ -904,17 +947,19 @@ export function StoryboardNode({ data, selected }: any) {
> >
{elementCrops.map((p) => { {elementCrops.map((p) => {
const key = `${p.frameIdx}_${p.elementId}` const key = `${p.frameIdx}_${p.elementId}`
const isPinned = pinnedPreviewCutout === key
return ( return (
<div <div
key={key} key={key}
className="group relative shrink-0 rounded-md border border-violet-300/50 overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-white" className="group relative shrink-0 rounded-md border border-violet-300/50 overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-white"
style={{ height: 160, aspectRatio: aspect }} style={{ height: 160, aspectRatio: aspect }}
onMouseEnter={(e) => setHoverPreviewCutout({ id: key, x: canvasAnchorX(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewCutout(null)}
> >
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setPinnedPreviewCutout((prev) => (prev === key ? null : key)) const x = canvasAnchorX(rootRef.current, e.currentTarget)
setPinnedPreviewCutout((prev) => (prev?.id === key ? null : { id: key, x }))
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx) if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
d.onOpenStoryboard?.(p.frameIdx) d.onOpenStoryboard?.(p.frameIdx)
d.onOpenWorkbench?.(p.frameIdx) d.onOpenWorkbench?.(p.frameIdx)
@@ -962,21 +1007,32 @@ export function StoryboardNode({ data, selected }: any) {
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
)} )}
<HoverPreview
imgSrc={p.frameSrc}
aspect={aspect}
label={`分镜 ${p.frameIdx + 1} · ${p.name}`}
caption={`${p.timestamp.toFixed(2)}s`}
borderClass="border-violet-300/60"
pinned={isPinned}
onClose={() => setPinnedPreviewCutout(null)}
/>
</div> </div>
) )
})} })}
</div> </div>
)} )}
{(() => {
const anchor = pinnedPreviewCutout ?? hoverPreviewCutout
if (!anchor) return null
const crop = elementCrops.find((p) => `${p.frameIdx}_${p.elementId}` === anchor.id)
if (!crop) return null
return (
<HoverPreview
imgSrc={crop.src}
aspect={aspect}
label={`分镜 ${crop.frameIdx + 1} · ${crop.name}`}
caption={`${crop.timestamp.toFixed(2)}s`}
borderClass="border-violet-300/60"
visible={!!hoverPreviewCutout && !pinnedPreviewCutout}
anchorX={anchor.x}
pinned={!!pinnedPreviewCutout}
onClose={() => setPinnedPreviewCutout(null)}
/>
)
})()}
<NodeShell <NodeShell
type="ai" status={status} type="ai" status={status}
icon={<LayoutGrid className="h-4 w-4" />} icon={<LayoutGrid className="h-4 w-4" />}
@@ -1014,6 +1070,8 @@ export function StoryboardNode({ data, selected }: any) {
export function VideoGenNode({ data, selected }: any) { export function VideoGenNode({ data, selected }: any) {
const d: NodeData = data const d: NodeData = data
const videos = d.job?.generated_videos ?? [] const videos = d.job?.generated_videos ?? []
const rootRef = useRef<HTMLDivElement>(null)
const [hoverPreviewVideo, setHoverPreviewVideo] = useState<PreviewAnchor<string> | null>(null)
const running = videos.some((v) => v.status === "queued" || v.status === "in_progress") const running = videos.some((v) => v.status === "queued" || v.status === "in_progress")
const completed = videos.filter((v) => v.status === "completed" && v.url) const completed = videos.filter((v) => v.status === "completed" && v.url)
const failed = videos.some((v) => v.status === "failed") const failed = videos.some((v) => v.status === "failed")
@@ -1028,15 +1086,8 @@ export function VideoGenNode({ data, selected }: any) {
if (m.includes("seedance")) return "Seedance" if (m.includes("seedance")) return "Seedance"
return model || "Video" return model || "Video"
} }
const readableVideoError = (error?: string) => {
const e = error || "生成失败"
if (e.includes("/videos") && e.includes("404")) {
return "模型已提交,但当前 /videos 入口返回 404需要配置实际视频生成入口"
}
return e
}
return ( return (
<div className="relative" style={{ width: "100%", height: "100%" }}> <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{videos.length > 0 && ( {videos.length > 0 && (
<div <div
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5" className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
@@ -1054,6 +1105,8 @@ export function VideoGenNode({ data, selected }: any) {
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55" ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
}`} }`}
style={{ height: 160, aspectRatio: aspect }} style={{ height: 160, aspectRatio: aspect }}
onMouseEnter={(e) => setHoverPreviewVideo({ id: v.id, x: canvasAnchorX(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewVideo(null)}
> >
<button <button
type="button" type="button"
@@ -1119,40 +1172,32 @@ export function VideoGenNode({ data, selected }: any) {
> >
<Trash2 className="h-2.5 w-2.5" /> <Trash2 className="h-2.5 w-2.5" />
</button> </button>
<div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
style={{
bottom: "calc(100% + 10px)",
left: "50%",
transform: "translateX(-50%)",
transformOrigin: "bottom center",
}}
>
<div className="rounded-lg overflow-hidden border-2 border-rose-300/60 bg-black shadow-2xl" style={{ width: 300 }}>
<div style={{ aspectRatio: aspect }}>
{ready ? (
<video src={videoSrc} poster={posterSrc} muted loop autoPlay playsInline controls className="h-full w-full object-cover" />
) : posterSrc ? (
<img src={posterSrc} alt="" className="w-full h-full object-cover" />
) : (
<div className="h-full w-full bg-violet-950/60" />
)}
</div>
<div className="space-y-1 bg-black/90 px-2 py-1.5 text-white">
<div className="flex items-center justify-between gap-2 text-[10.5px]">
<span className="truncate"> {v.frame_idx + 1}</span>
<span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
</div>
<div className="line-clamp-3 text-[9.5px] leading-snug text-white/55">
{v.status === "failed" ? readableVideoError(v.error) : v.prompt}
</div>
</div>
</div>
</div>
</div> </div>
)})} )})}
</div> </div>
)} )}
{(() => {
if (!hoverPreviewVideo) return null
const item = videos.find((v) => v.id === hoverPreviewVideo.id)
if (!item) return null
const videoSrc = apiAssetUrl(item.url)
const posterSrc = apiAssetUrl(item.poster_url)
const ready = item.status === "completed" && !!videoSrc
if (!ready && !posterSrc) return null
return (
<HoverPreview
videoSrc={ready ? videoSrc : undefined}
imgSrc={!ready ? posterSrc : undefined}
poster={posterSrc}
aspect={aspect}
label={`分镜 ${item.frame_idx + 1}`}
caption={`${modelLabel(item.model)} · ${item.status}`}
borderClass={ready ? "border-emerald-300/60" : "border-rose-300/60"}
visible
anchorX={hoverPreviewVideo.x}
/>
)
})()}
<NodeShell <NodeShell
type="ai" status={status} type="ai" status={status}
icon={<Film className="h-4 w-4" />} icon={<Film className="h-4 w-4" />}