auto-save 2026-05-14 01:57 (+1, ~4)
This commit is contained in:
@@ -83,7 +83,6 @@ const EDGES_RAW: Array<[string, string]> = [
|
||||
["asr", "translate"],
|
||||
["translate", "rewrite"],
|
||||
["keyframe", "storyboard"],
|
||||
["rewrite", "storyboard"],
|
||||
["storyboard", "videogen"],
|
||||
["videogen", "compose"],
|
||||
["rewrite", "compose"],
|
||||
|
||||
@@ -3,21 +3,23 @@ import { X } from "lucide-react"
|
||||
|
||||
/**
|
||||
* 视觉类节点统一大预览:
|
||||
* - **在 ReactFlow 节点 DOM 内**作为 absolute 元素,贴 thumb 上方边缘(bottom: calc(100% + 10px) + 居中)
|
||||
* - **在 ReactFlow 节点 DOM 内**作为 absolute 元素,贴节点卡片上边缘
|
||||
* - 跟随 ReactFlow 画布 pan/zoom 一起变化(属于"无限画布"的一部分)
|
||||
* - 媒体按"自然像素分辨率"渲染 + max-w/max-h 限制,避免占满整个画布
|
||||
* - 不 pinned 时:pointer-events-none,依赖 group-hover 显示/隐藏
|
||||
* - 媒体按"自然像素分辨率"渲染,不做 max 尺寸限制
|
||||
* - 不 pinned 时:pointer-events-none,依赖调用方传入 visible
|
||||
* - pinned=true:强制 visible,pointer-events 开启,可点 × 关闭
|
||||
* - 用法:父级容器要带 `group` class,HoverPreview 直接作为子元素
|
||||
* - 用法:渲染在节点根层,不要放进 overflow-x-auto 缩略图滚动条里
|
||||
*/
|
||||
interface Props {
|
||||
imgSrc?: string
|
||||
videoSrc?: string
|
||||
poster?: string
|
||||
aspect: string
|
||||
aspect?: string
|
||||
label?: string
|
||||
caption?: string
|
||||
borderClass?: string
|
||||
visible?: boolean
|
||||
anchorX?: number
|
||||
pinned?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -26,19 +28,22 @@ export function HoverPreview({
|
||||
imgSrc, videoSrc, poster, aspect,
|
||||
label, caption,
|
||||
borderClass = "border-violet-300/55",
|
||||
visible = false,
|
||||
anchorX,
|
||||
pinned = false,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const visibilityCls = pinned
|
||||
? "opacity-100 scale-100 pointer-events-auto"
|
||||
: "pointer-events-none opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100"
|
||||
const shown = pinned || visible
|
||||
const visibilityCls = shown
|
||||
? pinned ? "opacity-100 pointer-events-auto" : "opacity-100 pointer-events-none"
|
||||
: "pointer-events-none opacity-0"
|
||||
return (
|
||||
<div
|
||||
className={`absolute transition-all duration-150 z-[60] ${visibilityCls}`}
|
||||
className={`absolute transition-all duration-150 z-[120] ${visibilityCls}`}
|
||||
style={{
|
||||
bottom: "calc(100% + 10px)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
bottom: "calc(100% + 8px)",
|
||||
left: typeof anchorX === "number" ? `${anchorX}px` : "50%",
|
||||
transform: `translateX(-50%) scale(${shown ? 1 : 0.96})`,
|
||||
transformOrigin: "bottom center",
|
||||
}}
|
||||
>
|
||||
@@ -54,14 +59,19 @@ export function HoverPreview({
|
||||
autoPlay
|
||||
preload="auto"
|
||||
className="block"
|
||||
style={{ maxWidth: "none" }}
|
||||
style={{ width: "auto", height: "auto", maxWidth: "none", maxHeight: "none" }}
|
||||
onLoadedMetadata={(e) => { e.currentTarget.play().catch(() => {}) }}
|
||||
onCanPlay={(e) => { e.currentTarget.play().catch(() => {}) }}
|
||||
/>
|
||||
) : 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) && (
|
||||
<div className="px-2 py-1 bg-black/80 text-white text-[11px] flex items-center justify-between">
|
||||
|
||||
@@ -85,6 +85,17 @@ function asrStatus(job: Job | null): NodeStatus {
|
||||
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 链接 / 上传
|
||||
============================================================ */
|
||||
@@ -94,7 +105,9 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
const [videoT, setVideoT] = useState(0)
|
||||
const [addingFrame, setAddingFrame] = 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 videoRef = useRef<HTMLVideoElement>(null)
|
||||
const job = d.job
|
||||
@@ -120,7 +133,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
const inputLocked = isDownloading || d.submitting
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。
|
||||
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
|
||||
{!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"
|
||||
}`}
|
||||
style={{ height: 160, aspectRatio: aspectStr }}
|
||||
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, x: canvasAnchorX(rootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setHoverPreviewJob(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// 单击:钉住 / 取消钉住大预览 + 切换 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)
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
@@ -184,23 +200,33 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
{ready ? `${j.duration.toFixed(1)}s` : "…"}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
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 + 加帧按钮,不全屏 */}
|
||||
{hasVideo && job && videoExpanded && (
|
||||
<div
|
||||
@@ -399,7 +425,9 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
const frames = d.job?.frames ?? []
|
||||
const jobId = d.job?.id
|
||||
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 节点外的任何位置 → 取消 pin(capture 阶段,避免 ReactFlow pane 拦截)
|
||||
useEffect(() => {
|
||||
@@ -414,7 +442,7 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
}, [pinnedPreviewFrame])
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */}
|
||||
{frames.length > 0 && jobId && (
|
||||
<div
|
||||
@@ -437,11 +465,14 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
? `${d.job.width}/${d.job.height}`
|
||||
: "16/9",
|
||||
}}
|
||||
onMouseEnter={(e) => setHoverPreviewFrame({ id: f.index, x: canvasAnchorX(rootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setHoverPreviewFrame(null)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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)
|
||||
}}
|
||||
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" />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
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
|
||||
type="process" status={st}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
@@ -850,7 +892,9 @@ const IMAGEGEN_WIDTH = 360
|
||||
export function StoryboardNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -865,7 +909,7 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
}, [pinnedPreviewCutout])
|
||||
|
||||
// 上方浮条 = 所有 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
|
||||
? job.frames.flatMap((f) =>
|
||||
(f.elements ?? [])
|
||||
@@ -881,7 +925,6 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
name: e.name_zh,
|
||||
src,
|
||||
cid,
|
||||
frameSrc: effectiveFrameUrl(job.id, f),
|
||||
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"
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{/* 节点上方:所有元素 crop 图(编排输入素材)— 视觉类节点统一样板:单行横滚 + 左上复制 + 右上删除 + hover/click pin 大预览 */}
|
||||
{elementCrops.length > 0 && job && (
|
||||
<div
|
||||
@@ -904,17 +947,19 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
>
|
||||
{elementCrops.map((p) => {
|
||||
const key = `${p.frameIdx}_${p.elementId}`
|
||||
const isPinned = pinnedPreviewCutout === key
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
style={{ height: 160, aspectRatio: aspect }}
|
||||
onMouseEnter={(e) => setHoverPreviewCutout({ id: key, x: canvasAnchorX(rootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setHoverPreviewCutout(null)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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)
|
||||
d.onOpenStoryboard?.(p.frameIdx)
|
||||
d.onOpenWorkbench?.(p.frameIdx)
|
||||
@@ -962,21 +1007,32 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
<X className="h-3 w-3" />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
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
|
||||
type="ai" status={status}
|
||||
icon={<LayoutGrid className="h-4 w-4" />}
|
||||
@@ -1014,6 +1070,8 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
export function VideoGenNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
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 completed = videos.filter((v) => v.status === "completed" && v.url)
|
||||
const failed = videos.some((v) => v.status === "failed")
|
||||
@@ -1028,15 +1086,8 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
if (m.includes("seedance")) return "Seedance"
|
||||
return model || "Video"
|
||||
}
|
||||
const readableVideoError = (error?: string) => {
|
||||
const e = error || "生成失败"
|
||||
if (e.includes("/videos") && e.includes("404")) {
|
||||
return "模型已提交,但当前 /videos 入口返回 404;需要配置实际视频生成入口"
|
||||
}
|
||||
return e
|
||||
}
|
||||
return (
|
||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{videos.length > 0 && (
|
||||
<div
|
||||
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"
|
||||
}`}
|
||||
style={{ height: 160, aspectRatio: aspect }}
|
||||
onMouseEnter={(e) => setHoverPreviewVideo({ id: v.id, x: canvasAnchorX(rootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setHoverPreviewVideo(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1119,40 +1172,32 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</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>
|
||||
)}
|
||||
{(() => {
|
||||
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
|
||||
type="ai" status={status}
|
||||
icon={<Film className="h-4 w-4" />}
|
||||
|
||||
Reference in New Issue
Block a user