1138 lines
50 KiB
TypeScript
1138 lines
50 KiB
TypeScript
"use client"
|
||
import { useEffect, useRef, useState } from "react"
|
||
import { createPortal } from "react-dom"
|
||
import { type NodeProps, useReactFlow } from "@xyflow/react"
|
||
import {
|
||
Link2, Upload, Download, Scissors, Image as ImageIcon,
|
||
Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Pin, Maximize2,
|
||
Copy, Trash2,
|
||
} from "lucide-react"
|
||
import { toast } from "sonner"
|
||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||
import {
|
||
type Job, type ImageRef,
|
||
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
||
} from "@/lib/api"
|
||
import { FrameLightbox } from "@/components/lightbox"
|
||
|
||
export interface NodeData {
|
||
job: Job | null // 当前 active job
|
||
jobs: Job[] // 所有 job 列表
|
||
activeJobId: string | null
|
||
submitting: boolean
|
||
analyzing: boolean
|
||
selectedFrames: Set<number>
|
||
expandedFrame: number | null
|
||
framePanelScale?: number
|
||
framePanelPinned?: boolean
|
||
onSubmitUrl: (url: string) => void
|
||
onUploadFile: (file: File) => void
|
||
onAnalyze: () => void
|
||
onToggleFrame: (idx: number) => void
|
||
onExpandFrame: (idx: number) => void
|
||
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
|
||
onFramePanelScaleChange?: (scale: number) => void
|
||
onFramePanelPinnedChange?: (pinned: boolean) => void
|
||
onCloseExpandedFrame: () => void
|
||
onAddManualFrame: (t: number) => void
|
||
onOpenVideoLightbox: () => void
|
||
onSwitchJob: (id: string) => void
|
||
onJobUpdate: (j: Job) => void
|
||
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
|
||
onDeleteFrame?: (idx: number) => void // 删整张关键帧
|
||
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
|
||
onDeleteVideo?: (videoId: string) => void // 删 Video Gen 任务
|
||
onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板
|
||
onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板
|
||
onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽)
|
||
}
|
||
|
||
/* ---- 状态映射工具 ---- */
|
||
function inputStatus(job: Job | null): NodeStatus {
|
||
if (!job) return "pending"
|
||
return "done"
|
||
}
|
||
function downloadStatus(job: Job | null): NodeStatus {
|
||
if (!job) return "pending"
|
||
if (job.status === "failed" && job.progress < 30) return "failed"
|
||
if (job.status === "downloading") return "running"
|
||
if (job.video_url) return "done"
|
||
return "pending"
|
||
}
|
||
function splitStatus(job: Job | null): NodeStatus {
|
||
if (!job || !job.video_url) return "pending"
|
||
if (job.status === "failed" && job.progress >= 20 && job.progress < 50) return "failed"
|
||
if (job.status === "splitting") return "running"
|
||
if (["frames_extracted", "transcribing", "transcribed"].includes(job.status)) return "done"
|
||
return "pending"
|
||
}
|
||
function keyframeStatus(job: Job | null): NodeStatus {
|
||
if (!job) return "pending"
|
||
if (job.status === "failed" && job.progress >= 50 && job.progress < 70) return "failed"
|
||
if (job.frames.length === 0 && job.status === "splitting") return "running"
|
||
if (job.frames.length > 0) return "done"
|
||
return "pending"
|
||
}
|
||
function asrStatus(job: Job | null): NodeStatus {
|
||
if (!job) return "pending"
|
||
if (job.status === "transcribing") return "running"
|
||
if (job.transcript.length > 0) return "done"
|
||
if (job.status === "failed" && job.progress >= 70) return "failed"
|
||
return "pending"
|
||
}
|
||
|
||
/* ============================================================
|
||
1. InputNode — TK 链接 / 上传
|
||
============================================================ */
|
||
export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) {
|
||
const d: NodeData = data
|
||
const [url, setUrl] = useState("")
|
||
const [videoT, setVideoT] = useState(0)
|
||
const [addingFrame, setAddingFrame] = useState(false)
|
||
const [videoExpanded, setVideoExpanded] = useState(false)
|
||
const fileRef = useRef<HTMLInputElement>(null)
|
||
const videoRef = useRef<HTMLVideoElement>(null)
|
||
const job = d.job
|
||
|
||
// 是否已下载 → 显示视频 + 解析按钮
|
||
const hasVideo = !!job?.video_url
|
||
const isDownloading = job?.status === "downloading" || job?.status === "created"
|
||
const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status)
|
||
const isDone = job?.status === "transcribed"
|
||
const hasFrames = (job?.frames.length ?? 0) > 0
|
||
const inputLocked = isDownloading || d.submitting
|
||
|
||
return (
|
||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。
|
||
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
|
||
{!videoExpanded && d.jobs.length > 0 && (
|
||
<div
|
||
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
|
||
style={{ bottom: "calc(100% + 12px)" }}
|
||
>
|
||
{/* + 再上传一个(放在最前面) */}
|
||
<button
|
||
type="button"
|
||
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: 36, height: 64 }}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</button>
|
||
{[...d.jobs].reverse().map((j) => {
|
||
const isActive = j.id === d.activeJobId
|
||
const ready = !!j.video_url
|
||
return (
|
||
<button
|
||
key={j.id}
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (isActive && ready) setVideoExpanded(true)
|
||
else d.onSwitchJob(j.id)
|
||
}}
|
||
title={ready ? `${j.width}×${j.height} · ${j.duration.toFixed(1)}s · ${isActive ? "点击展开" : "点击切换"}` : "下载中…"}
|
||
className={`shrink-0 group relative rounded-md overflow-hidden border shadow-lg transition hover:-translate-y-0.5 ${
|
||
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
|
||
}`}
|
||
style={{ height: 64, aspectRatio: ready ? `${j.width}/${j.height}` : "9/16" }}
|
||
>
|
||
{ready ? (
|
||
<video
|
||
src={videoUrl(j.id)}
|
||
muted
|
||
loop
|
||
playsInline
|
||
preload="metadata"
|
||
className="block w-full h-full object-cover bg-black"
|
||
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
|
||
onMouseLeave={(e) => {
|
||
const v = e.target as HTMLVideoElement
|
||
v.pause()
|
||
v.currentTime = 0
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full bg-black/60 flex items-center justify-center">
|
||
<Loader2 className="h-4 w-4 animate-spin text-white/60" />
|
||
</div>
|
||
)}
|
||
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
|
||
{ready ? `${j.duration.toFixed(1)}s` : "…"}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* 展开态 — 稍微放大(360 宽),含 controls + 加帧按钮,不全屏 */}
|
||
{hasVideo && job && videoExpanded && (
|
||
<div
|
||
className="absolute left-0 right-0 flex justify-center"
|
||
style={{ bottom: "calc(100% + 12px)" }}
|
||
>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="relative rounded-xl overflow-hidden border border-white/25 shadow-2xl bg-black"
|
||
style={{ width: 360, animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
|
||
>
|
||
<video
|
||
ref={videoRef}
|
||
src={videoUrl(job.id)}
|
||
controls
|
||
autoPlay
|
||
playsInline
|
||
preload="auto"
|
||
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
|
||
className="block w-full bg-black"
|
||
style={{ aspectRatio: `${job.width}/${job.height}`, maxHeight: "60vh" }}
|
||
/>
|
||
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={addingFrame}
|
||
onClick={async (e) => {
|
||
e.stopPropagation()
|
||
const t = videoRef.current?.currentTime ?? videoT
|
||
setAddingFrame(true)
|
||
try { await d.onAddManualFrame(t) } finally { setAddingFrame(false) }
|
||
}}
|
||
className="flex-1 text-[11.5px] py-1.5 rounded-md bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-50 inline-flex items-center justify-center gap-1.5 font-medium"
|
||
>
|
||
{addingFrame ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||
+ 把 {videoT.toFixed(1)}s 加为关键帧
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); setVideoExpanded(false) }}
|
||
className="px-2.5 py-1.5 text-[11px] rounded-md bg-white/10 hover:bg-white/20 text-white"
|
||
>
|
||
收起
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<NodeShell
|
||
type="input" status={inputStatus(job)}
|
||
icon={<Link2 className="h-4 w-4" />}
|
||
title="输入 · Input"
|
||
subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"}
|
||
selected={selected}
|
||
hasTarget={false}
|
||
>
|
||
{/* URL + 上传入口 — 一直显示(即使已有视频,也可以继续加新的) */}
|
||
<>
|
||
<input
|
||
value={url}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
placeholder={hasVideo ? "再加一个 TK 链接" : "粘贴 TikTok 链接"}
|
||
disabled={inputLocked}
|
||
className="w-full text-[12px] px-2.5 py-2 rounded-md bg-white/60 dark:bg-black/40 border border-black/10 dark:border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40"
|
||
/>
|
||
<div className="mt-2 flex gap-1.5">
|
||
<button
|
||
type="button"
|
||
disabled={inputLocked || !url.trim()}
|
||
onClick={() => { d.onSubmitUrl(url.trim()); setUrl("") }}
|
||
className="flex-1 text-[11.5px] py-1.5 rounded-md bg-black text-white dark:bg-white dark:text-black hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||
>
|
||
{(d.submitting || isDownloading) ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||
{isDownloading ? "下载中…" : hasVideo ? "+ 加链接" : "提交链接"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={inputLocked}
|
||
onClick={() => fileRef.current?.click()}
|
||
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/60 dark:bg-white/[0.06] border border-black/10 dark:border-white/15 hover:bg-white/80 dark:hover:bg-white/[0.12] inline-flex items-center gap-1 disabled:opacity-30"
|
||
>
|
||
<Upload className="h-3 w-3" /> {hasVideo ? "再传一个" : "上传"}
|
||
</button>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="video/mp4,video/quicktime,video/webm,video/x-matroska,.mp4,.mov,.webm,.mkv,.m4v"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0]
|
||
if (f) d.onUploadFile(f)
|
||
e.target.value = ""
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
|
||
{/* 已下载:仅元数据(视频缩略图浮在节点上方,点击进 lightbox) */}
|
||
{hasVideo && job && (
|
||
<>
|
||
<div className="rounded-md bg-black/30 border border-black/10 dark:border-white/10 px-3 py-2 flex items-center justify-between text-[10.5px] font-mono">
|
||
<span className="text-[var(--text-strong)]">{job.width}×{job.height} · {job.duration.toFixed(1)}s</span>
|
||
<span className="text-[var(--text-faint)]">{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
disabled={isAnalyzing || d.analyzing}
|
||
onClick={d.onAnalyze}
|
||
className={`mt-2 w-full text-[14px] py-3 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-semibold shadow-lg shadow-violet-500/30 ${
|
||
!isAnalyzing && !d.analyzing && !isDone && !hasFrames ? "animate-[pulse_2s_ease-in-out_infinite] ring-2 ring-violet-400/40 ring-offset-2 ring-offset-transparent" : ""
|
||
}`}
|
||
>
|
||
{(isAnalyzing || d.analyzing) ? (
|
||
<><Loader2 className="h-4 w-4 animate-spin" /> 解析中…</>
|
||
) : isDone || hasFrames ? (
|
||
"重新解析"
|
||
) : (
|
||
<>▶ 点这里开始解析</>
|
||
)}
|
||
</button>
|
||
</>
|
||
)}
|
||
</NodeShell>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
2. DownloadNode
|
||
============================================================ */
|
||
export function DownloadNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const st = downloadStatus(d.job)
|
||
return (
|
||
<NodeShell
|
||
type="process" status={st}
|
||
icon={<Download className="h-4 w-4" />}
|
||
title="下载 · Download"
|
||
subtitle="STEP 2 · yt-dlp"
|
||
selected={selected}
|
||
>
|
||
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
|
||
{d.job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"}
|
||
</div>
|
||
{d.job && st === "done" && (
|
||
<div className="mt-2 grid grid-cols-2 gap-2 text-[10.5px] font-mono text-[var(--text-faint)]">
|
||
<div>分辨率<br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.width}×{d.job.height}</span></div>
|
||
<div>时长<br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.duration.toFixed(1)}s</span></div>
|
||
</div>
|
||
)}
|
||
</NodeShell>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
3. SplitNode
|
||
============================================================ */
|
||
export function SplitNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
return (
|
||
<NodeShell
|
||
type="process" status={splitStatus(d.job)}
|
||
icon={<Scissors className="h-4 w-4" />}
|
||
title="拆分 · Split"
|
||
subtitle="STEP 3 · ffmpeg"
|
||
selected={selected}
|
||
>
|
||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||
<div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">视频流</div>
|
||
<div className="text-[var(--text-strong)] mt-0.5">→ 关键帧</div>
|
||
</div>
|
||
<div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
|
||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">音频流</div>
|
||
<div className="text-[var(--text-strong)] mt-0.5">→ ASR</div>
|
||
</div>
|
||
</div>
|
||
</NodeShell>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
4. KeyframeNode — 缩略图横排浮在节点上方,点击展开 lightbox
|
||
============================================================ */
|
||
const KEYFRAME_WIDTH = 360
|
||
const THUMB_W = 64
|
||
const THUMB_GAP = 6
|
||
|
||
export function KeyframeNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const st = keyframeStatus(d.job)
|
||
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"
|
||
|
||
return (
|
||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{/* 缩略图浮条(节点上方,最多 5 个一行,多行向上扩展) */}
|
||
{frames.length > 0 && jobId && (
|
||
<div
|
||
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
|
||
style={{ bottom: "calc(100% + 12px)" }}
|
||
>
|
||
{frames.map((f) => {
|
||
const isSel = d.selectedFrames.has(f.index)
|
||
return (
|
||
<div
|
||
key={f.index}
|
||
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
|
||
isSel
|
||
? "border-emerald-400 ring-2 ring-emerald-400/60"
|
||
: "border-white/30 dark:border-white/20"
|
||
}`}
|
||
style={{
|
||
aspectRatio: d.job && d.job.height > 0
|
||
? `${d.job.width}/${d.job.height}`
|
||
: "16/9",
|
||
}}
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index)
|
||
}}
|
||
title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · hover 看大图 · 点击打开 / 找回详情面板`}
|
||
className="absolute inset-0 w-full h-full"
|
||
>
|
||
<img
|
||
src={effectiveFrameUrl(jobId, f)}
|
||
alt={`frame ${f.index}`}
|
||
className="absolute inset-0 w-full h-full object-cover rounded-md"
|
||
/>
|
||
{isSel && (
|
||
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
|
||
)}
|
||
{(f.cleaned_url || (f.elements?.some((e) => hasCutout(e)))) && (
|
||
<div className="absolute top-0 left-0 flex items-center gap-0.5 px-1 py-0.5 rounded-br-md leading-none">
|
||
{f.cleaned_url && (
|
||
<span title="已清洗" className="bg-cyan-500/85 text-white text-[8px] font-bold px-1 py-0.5 rounded-sm">✨</span>
|
||
)}
|
||
{(() => {
|
||
const cutN = f.elements?.filter((e) => hasCutout(e)).length ?? 0
|
||
return cutN > 0 ? (
|
||
<span title={`${cutN} 个元素已抠图`} className="bg-violet-500/85 text-white text-[8px] font-mono font-bold px-1 py-0.5 rounded-sm">
|
||
{cutN}
|
||
</span>
|
||
) : null
|
||
})()}
|
||
</div>
|
||
)}
|
||
<div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-bl rounded-br-md">
|
||
{f.timestamp.toFixed(1)}s
|
||
</div>
|
||
</button>
|
||
{/* 复制按钮:常驻可见 — 复制该关键帧到剪贴板 */}
|
||
{d.onCopyImage && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
d.onCopyImage?.({
|
||
kind: "keyframe",
|
||
frame_idx: f.index,
|
||
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"
|
||
>
|
||
📋
|
||
</button>
|
||
)}
|
||
{/* 删除按钮:hover 时右上角浮出 */}
|
||
{d.onDeleteFrame && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (confirm(`删除分镜 ${f.index + 1}(${f.timestamp.toFixed(1)}s)?相关清洗 / 抠图 / 生成图都会一并清除。`)) {
|
||
d.onDeleteFrame?.(f.index)
|
||
}
|
||
}}
|
||
title="删除该关键帧"
|
||
className="absolute top-1 right-1 h-5 w-5 rounded-full bg-black/70 backdrop-blur text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-[70]"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
{/* hover 预览 — absolute 浮在缩略图上方,跟着 ReactFlow 画布缩放平移 */}
|
||
<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-orange-300/50 bg-black shadow-2xl" style={{ width: 280 }}>
|
||
<div style={{ aspectRatio: aspectStr }}>
|
||
<img src={effectiveFrameUrl(jobId, f)} alt="" className="w-full h-full object-cover" />
|
||
</div>
|
||
<div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between">
|
||
<span>分镜 {f.index + 1}</span>
|
||
<span className="text-white/60 font-mono">{f.timestamp.toFixed(2)}s</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<NodeShell
|
||
type="process" status={st}
|
||
icon={<ImageIcon className="h-4 w-4" />}
|
||
title="镜头拆解 · 元素提取"
|
||
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`}
|
||
selected={selected}
|
||
>
|
||
{frames.length > 0 ? (() => {
|
||
const cleanedCount = frames.filter((x) => x.cleaned_url).length
|
||
const elementsCount = frames.reduce((s, x) => s + (x.elements?.length ?? 0), 0)
|
||
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
|
||
return (
|
||
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
|
||
自动 <span className="text-[var(--text-strong)] font-medium">{frames.length}</span> 张
|
||
{" · "}
|
||
<span className={cleanedCount > 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} 已清洗</span>
|
||
{" · "}
|
||
<span className={cutoutCount > 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} 已抠图</span>
|
||
<br />
|
||
<span className="text-[10.5px] text-[var(--text-faint)]">
|
||
点缩略图 → 清洗水印 / 提取可借鉴元素 → 改造成 SKG 画面素材
|
||
</span>
|
||
</div>
|
||
)
|
||
})() : (
|
||
<div className="text-[11.5px] text-[var(--text-faint)] py-1">
|
||
等待解析(默认 5 张)
|
||
</div>
|
||
)}
|
||
</NodeShell>
|
||
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
4b. KeyframePanelNode — 画布内可移动详情面板
|
||
============================================================ */
|
||
export function KeyframePanelNode({ data }: any) {
|
||
const d: NodeData = data
|
||
const { getZoom } = useReactFlow()
|
||
const panelRef = useRef<HTMLDivElement>(null)
|
||
const [pinRect, setPinRect] = useState<{ left: number; top: number }>({ left: 24, top: 72 })
|
||
const scale = d.framePanelScale ?? 1
|
||
const pinned = d.framePanelPinned ?? false
|
||
|
||
const getStoryboardDockTop = () => {
|
||
if (typeof window === "undefined") return 64
|
||
const dock = document.querySelector<HTMLElement>('[data-storyboard-dock="true"]')
|
||
const bar = document.querySelector<HTMLElement>('[data-storyboard-bar="true"]')
|
||
const bottom = (dock ?? bar)?.getBoundingClientRect().bottom ?? 52
|
||
return Math.max(56, Math.min(window.innerHeight - 120, bottom + 10))
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!pinned || typeof window === "undefined") return
|
||
|
||
const syncDock = () => {
|
||
setPinRect({ left: 16, top: getStoryboardDockTop() })
|
||
}
|
||
|
||
syncDock()
|
||
const bar = document.querySelector<HTMLElement>('[data-storyboard-dock="true"]')
|
||
?? document.querySelector<HTMLElement>('[data-storyboard-bar="true"]')
|
||
let observer: ResizeObserver | null = null
|
||
if (bar && "ResizeObserver" in window) {
|
||
observer = new ResizeObserver(syncDock)
|
||
observer.observe(bar)
|
||
}
|
||
window.addEventListener("resize", syncDock)
|
||
|
||
return () => {
|
||
observer?.disconnect()
|
||
window.removeEventListener("resize", syncDock)
|
||
}
|
||
}, [pinned])
|
||
|
||
if (!d.job || d.expandedFrame === null) return null
|
||
const active = d.job.frames.find((f) => f.index === d.expandedFrame)
|
||
const panelWidth = Math.round(760 * scale)
|
||
const panelHeight = Math.round(746 * scale)
|
||
const bodyHeight = Math.max(520, panelHeight - 27)
|
||
const setScale = (next: number) => {
|
||
const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2))))
|
||
d.onFramePanelScaleChange?.(clamped)
|
||
}
|
||
|
||
const togglePinned = () => {
|
||
if (!pinned) {
|
||
const zoom = getZoom()
|
||
setScale(scale * zoom)
|
||
setPinRect({ left: 16, top: getStoryboardDockTop() })
|
||
}
|
||
d.onFramePanelPinnedChange?.(!pinned)
|
||
}
|
||
|
||
const startResize = (e: React.PointerEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const startX = e.clientX
|
||
const startY = e.clientY
|
||
const startScale = scale
|
||
const zoom = pinned ? 1 : getZoom()
|
||
const onMove = (ev: PointerEvent) => {
|
||
const dx = (ev.clientX - startX) / zoom
|
||
const dy = (ev.clientY - startY) / zoom
|
||
const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 746
|
||
setScale(startScale + delta)
|
||
}
|
||
const onUp = () => {
|
||
window.removeEventListener("pointermove", onMove)
|
||
window.removeEventListener("pointerup", onUp)
|
||
}
|
||
window.addEventListener("pointermove", onMove)
|
||
window.addEventListener("pointerup", onUp)
|
||
}
|
||
|
||
const panel = (
|
||
<div
|
||
ref={panelRef}
|
||
className="relative rounded-2xl border border-white/15 bg-black/70 shadow-2xl overflow-hidden"
|
||
style={{ width: panelWidth, height: panelHeight, boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)" }}
|
||
>
|
||
<div className={`keyframe-panel-drag flex h-7 items-center justify-between bg-gradient-to-r from-orange-500 to-red-500 px-3 text-white ${pinned ? "cursor-default" : "cursor-move"}`}>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<ImageIcon className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="truncate text-[12px] font-semibold">关键帧详情 · 元素提取</span>
|
||
<span className="shrink-0 text-[10px] font-mono text-white/65">
|
||
{active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="mr-1 text-[10px] text-white/60">
|
||
{pinned ? "已钉住左侧 · 不跟画布" : "拖动标题栏移动 · 可钉住"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); togglePinned() }}
|
||
className={`nodrag h-5 w-5 rounded inline-flex items-center justify-center transition ${
|
||
pinned
|
||
? "bg-white text-orange-600 shadow"
|
||
: "bg-white/10 text-white/85 hover:bg-white/20 hover:text-white"
|
||
}`}
|
||
title={pinned ? "取消钉住,回到画布节点" : "钉住到左侧,脱离画布缩放"}
|
||
>
|
||
<Pin className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }}
|
||
className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[13px] leading-none"
|
||
title="缩小面板"
|
||
>
|
||
-
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); setScale(1) }}
|
||
className="nodrag h-5 min-w-9 rounded bg-white/10 px-1.5 text-[10px] font-mono text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
|
||
title="重置为 100%"
|
||
>
|
||
{Math.round(scale * 100)}%
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); setScale(scale + 0.1) }}
|
||
className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[13px] leading-none"
|
||
title="放大面板"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
|
||
className="nodrag h-5 w-5 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
|
||
title="关闭"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="nodrag nowheel" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
|
||
<FrameLightbox
|
||
embedded
|
||
jobId={d.job.id}
|
||
frames={d.job.frames}
|
||
activeIndex={d.expandedFrame}
|
||
selected={d.selectedFrames}
|
||
onClose={d.onCloseExpandedFrame}
|
||
onChange={d.onExpandFrame}
|
||
onToggleSelect={d.onToggleFrame}
|
||
onJobUpdate={d.onJobUpdate}
|
||
onCopyImage={d.onCopyImage}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onPointerDown={startResize}
|
||
className="nodrag absolute bottom-0 right-0 z-[5] h-7 w-7 cursor-nwse-resize rounded-tl-md bg-white/10 text-white/65 hover:bg-orange-400/35 hover:text-white inline-flex items-center justify-center"
|
||
title="拖动右下角缩放面板"
|
||
>
|
||
<Maximize2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)
|
||
|
||
if (pinned && typeof document !== "undefined") {
|
||
return createPortal(
|
||
<div
|
||
className="fixed z-[240]"
|
||
style={{ left: pinRect.left, top: pinRect.top }}
|
||
>
|
||
{panel}
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
return panel
|
||
}
|
||
|
||
/* ============================================================
|
||
5. ASRNode — Gemini 转录
|
||
============================================================ */
|
||
export function ASRNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
return (
|
||
<NodeShell
|
||
type="ai" status={asrStatus(d.job)}
|
||
icon={<Mic className="h-4 w-4" />}
|
||
title="声音文案 · ASR"
|
||
subtitle="STEP 3 · 可选文案轨"
|
||
selected={selected}
|
||
>
|
||
<div className="text-[11.5px] text-[var(--text-soft)]">
|
||
Gemini 2.5 · 英文带时间戳分段
|
||
</div>
|
||
{d.job && d.job.transcript.length > 0 && (
|
||
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
|
||
{d.job.transcript.slice(0, 3).map((s) => (
|
||
<div key={s.index} className="leading-snug">
|
||
<span className="text-[var(--text-faint)] font-mono text-[10px] mr-1">
|
||
{s.start.toFixed(1)}s
|
||
</span>
|
||
{s.en.slice(0, 60)}
|
||
{s.en.length > 60 && "…"}
|
||
</div>
|
||
))}
|
||
{d.job.transcript.length > 3 && (
|
||
<div className="text-[var(--text-faint)] text-[10px]">还有 {d.job.transcript.length - 3} 段…</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</NodeShell>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
6. TranslateNode
|
||
============================================================ */
|
||
export function TranslateNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const hasZh = d.job?.transcript.some((s) => s.zh) ?? false
|
||
const st: NodeStatus = !d.job ? "pending" :
|
||
d.job.status === "transcribing" ? "running" :
|
||
hasZh ? "done" :
|
||
d.job.status === "failed" ? "failed" : "pending"
|
||
return (
|
||
<NodeShell
|
||
type="ai" status={st}
|
||
icon={<Languages className="h-4 w-4" />}
|
||
title="翻译理解 · Translate"
|
||
subtitle="STEP 4 · EN → ZH"
|
||
selected={selected}
|
||
>
|
||
<div className="text-[11.5px] text-[var(--text-soft)]">
|
||
中文翻译 · 段落级 · 实时输出
|
||
</div>
|
||
{hasZh && d.job && (
|
||
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
|
||
{d.job.transcript.slice(0, 3).map((s) => (
|
||
<div key={s.index} className="leading-snug">{s.zh.slice(0, 30)}{s.zh.length > 30 && "…"}</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</NodeShell>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
7. RewriteNode (placeholder)
|
||
============================================================ */
|
||
export function RewriteNode({ selected }: any) {
|
||
return (
|
||
<NodeShell
|
||
type="ai" status="pending"
|
||
icon={<FileEdit className="h-4 w-4" />}
|
||
title="产品文案 · Rewrite"
|
||
subtitle="STEP 5 · 接 SKG 卖点"
|
||
selected={selected}
|
||
>
|
||
<textarea
|
||
placeholder="粘贴 SKG 产品信息 / 关键卖点(可作为视频脚本和镜头动作参考)"
|
||
rows={3}
|
||
disabled
|
||
className="w-full text-[11.5px] px-2.5 py-2 rounded-md bg-white/30 dark:bg-white/[0.03] border border-dashed border-black/15 dark:border-white/10 placeholder:text-[var(--text-faint)] text-[var(--text-strong)] resize-none opacity-70"
|
||
/>
|
||
<div className="mt-1.5 text-[10px] text-[var(--text-faint)]">下一冲刺接入</div>
|
||
</NodeShell>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
6. StoryboardNode — 元素改造 + 分镜编排入口
|
||
============================================================ */
|
||
const IMAGEGEN_WIDTH = 360
|
||
|
||
export function StoryboardNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const job = d?.job
|
||
|
||
// 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材)
|
||
type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; frameSrc: string; timestamp: number }
|
||
const elementCrops: ElPreview[] = job
|
||
? job.frames.flatMap((f) =>
|
||
(f.elements ?? [])
|
||
.filter((e) => hasCutout(e))
|
||
.map((e) => {
|
||
const src = representativeCutoutUrl(job.id, f.index, e) || ""
|
||
const cid = (e.cutouts && e.cutouts.length > 0)
|
||
? e.cutouts[e.cutouts.length - 1]
|
||
: (e.cutout_id ?? "")
|
||
return {
|
||
frameIdx: f.index,
|
||
elementId: e.id,
|
||
name: e.name_zh,
|
||
src,
|
||
cid,
|
||
frameSrc: effectiveFrameUrl(job.id, f),
|
||
timestamp: f.timestamp,
|
||
}
|
||
})
|
||
.filter((p) => p.src),
|
||
)
|
||
: []
|
||
|
||
const totalElements = elementCrops.length
|
||
const storyboardCount = job?.frames.filter((f) => d.selectedFrames.has(f.index)).length ?? 0
|
||
const status: NodeStatus = !job ? "pending" : storyboardCount > 0 || totalElements > 0 ? "done" : "pending"
|
||
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
|
||
|
||
return (
|
||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{/* 节点上方:所有元素 crop 图(编排输入素材)· 跟 keyframe 节点样式一致 */}
|
||
{elementCrops.length > 0 && job && (
|
||
<div
|
||
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
|
||
style={{ bottom: "calc(100% + 12px)" }}
|
||
>
|
||
{elementCrops.map((p) => {
|
||
const key = `${p.frameIdx}_${p.elementId}`
|
||
return (
|
||
<div
|
||
key={key}
|
||
className="group relative rounded-md border border-violet-300/50 transition shadow-lg hover:-translate-y-0.5 bg-white"
|
||
style={{ aspectRatio: aspect }}
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (!d.selectedFrames.has(p.frameIdx)) {
|
||
d.onToggleFrame(p.frameIdx)
|
||
}
|
||
d.onOpenStoryboard?.(p.frameIdx)
|
||
d.onOpenWorkbench?.(p.frameIdx)
|
||
}}
|
||
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · 点击进入分镜编排`}
|
||
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-white"
|
||
>
|
||
<img
|
||
src={p.src}
|
||
alt={p.name}
|
||
className="absolute inset-0 w-full h-full object-contain"
|
||
/>
|
||
</button>
|
||
{/* 复制按钮:常驻可见 — 复制元素提取图到剪贴板 */}
|
||
{d.onCopyImage && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
d.onCopyImage?.({
|
||
kind: "cutout",
|
||
frame_idx: p.frameIdx,
|
||
element_id: p.elementId,
|
||
cutout_id: p.cid,
|
||
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"
|
||
>
|
||
📋
|
||
</button>
|
||
)}
|
||
{/* hover 预览 — 和关键帧节点保持一致:只看来源原帧,不额外展示元素面板 */}
|
||
<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-violet-300/60 bg-black shadow-2xl" style={{ width: 280 }}>
|
||
<div style={{ aspectRatio: aspect }}>
|
||
<img src={p.frameSrc} alt="" className="w-full h-full object-cover" />
|
||
</div>
|
||
<div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between">
|
||
<span>分镜 {p.frameIdx + 1}</span>
|
||
<span className="shrink-0 font-mono text-white/55">{p.timestamp.toFixed(2)}s</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<NodeShell
|
||
type="ai" status={status}
|
||
icon={<LayoutGrid className="h-4 w-4" />}
|
||
title="元素改造 · Storyboard"
|
||
subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
|
||
selected={selected}
|
||
>
|
||
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
|
||
不是复刻原视频:先把参考图里的主体 / 场景 / 动作 / 道具拆出来,再替换成 SKG 产品画面。
|
||
<br />
|
||
<span className="text-[10.5px] text-[var(--text-faint)]">
|
||
已有 {totalElements} 个提取元素 · {storyboardCount} 个分镜进入编排
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.() }}
|
||
disabled={!job || storyboardCount === 0}
|
||
className="mt-2 w-full rounded-md bg-gradient-to-r from-violet-500 to-pink-500 px-3 py-2 text-[12px] font-semibold text-white shadow-lg shadow-violet-500/25 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-35"
|
||
title={storyboardCount === 0 ? "先在关键帧节点选用分镜" : "进入 4 图槽分镜编排"}
|
||
>
|
||
进入分镜编排
|
||
</button>
|
||
</NodeShell>
|
||
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
9. VideoGenNode (placeholder)
|
||
============================================================ */
|
||
export function VideoGenNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const videos = d.job?.generated_videos ?? []
|
||
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")
|
||
const status: NodeStatus = running ? "running" : completed.length > 0 ? "done" : failed ? "failed" : "pending"
|
||
const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
|
||
? `${d.job.width}/${d.job.height}`
|
||
: "9/16"
|
||
const modelLabel = (model: string) => {
|
||
const m = model.toLowerCase()
|
||
if (m.includes("kling")) return "Kling"
|
||
if (m.includes("veo")) return "Veo 3"
|
||
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%" }}>
|
||
{videos.length > 0 && (
|
||
<div
|
||
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
|
||
style={{ bottom: "calc(100% + 12px)" }}
|
||
>
|
||
{videos.slice(0, 6).map((v, i) => {
|
||
const videoSrc = apiAssetUrl(v.url)
|
||
const posterSrc = apiAssetUrl(v.poster_url)
|
||
const ready = v.status === "completed" && !!videoSrc
|
||
const progress = Math.max(0, Math.min(100, v.progress || 0))
|
||
return (
|
||
<div
|
||
key={v.id}
|
||
className={`group relative rounded-md border 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={{ aspectRatio: aspect }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
|
||
toast.success("已复制视频 prompt")
|
||
}}
|
||
title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 prompt`}
|
||
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
|
||
>
|
||
{ready ? (
|
||
<video
|
||
src={videoSrc}
|
||
poster={posterSrc}
|
||
muted
|
||
loop
|
||
playsInline
|
||
preload="metadata"
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
|
||
onMouseLeave={(e) => {
|
||
const el = e.target as HTMLVideoElement
|
||
el.pause()
|
||
el.currentTime = 0
|
||
}}
|
||
/>
|
||
) : posterSrc ? (
|
||
<img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
|
||
) : (
|
||
<div className="absolute inset-0 bg-violet-950/50" />
|
||
)}
|
||
{!ready && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
|
||
{v.status === "failed" ? (
|
||
<X className="h-4 w-4 text-rose-200" />
|
||
) : (
|
||
<Loader2 className="h-4 w-4 animate-spin text-white/85" />
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-1.5 py-1 text-left">
|
||
<div className="truncate text-[9.5px] font-semibold text-white">视频 {i + 1}</div>
|
||
<div className="truncate text-[8.5px] font-mono text-white/60">
|
||
{ready ? `${v.duration.toFixed(0)}s` : v.status === "failed" ? "failed" : `${progress}%`}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
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"
|
||
title="复制视频 prompt"
|
||
>
|
||
<Copy className="h-2.5 w-2.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
d.onDeleteVideo?.(v.id)
|
||
}}
|
||
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"
|
||
title="删除这个视频任务"
|
||
>
|
||
<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>
|
||
)}
|
||
<NodeShell
|
||
type="ai" status={status}
|
||
icon={<Film className="h-4 w-4" />}
|
||
title="生成视频 · Video Gen"
|
||
subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`}
|
||
selected={selected}
|
||
>
|
||
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
|
||
{["Seedance", "Kling", "Veo 3"].map((m) => (
|
||
<div key={m} className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-center text-[var(--text-faint)]">
|
||
<span className="text-[var(--text-strong)] text-[11px]">{m}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{videos.length > 0 && (
|
||
<div className="mt-2 rounded-md border border-rose-300/25 bg-rose-500/10 px-2 py-1.5 text-[10.5px] text-[var(--text-soft)]">
|
||
已提交 {videos.length} 个视频任务 · 完成 {completed.length} 个{running ? " · 生成中" : ""}
|
||
</div>
|
||
)}
|
||
</NodeShell>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
10. ComposeNode (placeholder)
|
||
============================================================ */
|
||
export function ComposeNode({ selected }: any) {
|
||
return (
|
||
<NodeShell
|
||
type="output" status="pending"
|
||
icon={<FileVideo className="h-4 w-4" />}
|
||
title="合成成品 · Compose"
|
||
subtitle="STEP 8 · ffmpeg + 字幕"
|
||
selected={selected}
|
||
hasSource={false}
|
||
>
|
||
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
|
||
视频片段 + 字幕 / TTS<br />→ 最终 mp4 输出
|
||
</div>
|
||
</NodeShell>
|
||
)
|
||
}
|