1859 lines
80 KiB
TypeScript
1859 lines
80 KiB
TypeScript
"use client"
|
||
import {
|
||
useEffect, useRef, useState,
|
||
type PointerEvent as ReactPointerEvent,
|
||
type ReactNode,
|
||
type RefObject,
|
||
} 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 { HoverPreview } from "./hover-preview"
|
||
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
|
||
onDeleteJob?: (id: string) => void
|
||
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
|
||
onDeleteFrame?: (idx: number) => void // 删整张关键帧
|
||
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
|
||
onDeleteVideo?: (videoId: string) => void // 删 Video Gen 任务
|
||
onDeleteCutout?: (frameIdx: number, elementId: string, cutoutId: string) => void // 删元素提取图
|
||
onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板
|
||
onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板
|
||
onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽)
|
||
pinnedNodes?: Set<string> // 已钉住的节点 id 集合 — 钉住后位置 + 尺寸锁定
|
||
onToggleNodePin?: (id: string) => 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"
|
||
}
|
||
|
||
type PreviewAnchor<T extends string | number> = { id: T; x: number; y: number }
|
||
|
||
type ScrollRailState = {
|
||
visible: boolean
|
||
leftPct: number
|
||
widthPct: number
|
||
now: number
|
||
max: number
|
||
}
|
||
|
||
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()
|
||
const targetRect = target.getBoundingClientRect()
|
||
if (rootRect.width <= 0 || rootRect.height <= 0) return { x: root.clientWidth / 2, y: 0 }
|
||
const xRatio = (targetRect.left + targetRect.width / 2 - rootRect.left) / rootRect.width
|
||
const yRatio = (targetRect.top - rootRect.top) / rootRect.height
|
||
return {
|
||
x: xRatio * root.clientWidth,
|
||
y: yRatio * root.clientHeight,
|
||
}
|
||
}
|
||
|
||
function ThumbnailScrollRail({
|
||
scrollRef,
|
||
label = "缩略图横向滑动条",
|
||
}: {
|
||
scrollRef: RefObject<HTMLDivElement | null>
|
||
label?: string
|
||
}) {
|
||
const railRef = useRef<HTMLDivElement>(null)
|
||
const dragRef = useRef<{
|
||
pointerId: number
|
||
startX: number
|
||
startScrollLeft: number
|
||
maxScroll: number
|
||
trackRange: number
|
||
} | null>(null)
|
||
const [dragging, setDragging] = useState(false)
|
||
const [rail, setRail] = useState<ScrollRailState>({
|
||
visible: false,
|
||
leftPct: 0,
|
||
widthPct: 100,
|
||
now: 0,
|
||
max: 0,
|
||
})
|
||
|
||
useEffect(() => {
|
||
const el = scrollRef.current
|
||
if (!el) return
|
||
|
||
const update = () => {
|
||
const max = Math.max(0, el.scrollWidth - el.clientWidth)
|
||
const visible = max > 2
|
||
const widthPct = visible
|
||
? Math.max(26, Math.min(92, (el.clientWidth / Math.max(el.scrollWidth, 1)) * 100))
|
||
: 100
|
||
const leftPct = visible ? (el.scrollLeft / max) * (100 - widthPct) : 0
|
||
const next = {
|
||
visible,
|
||
leftPct: Number(leftPct.toFixed(2)),
|
||
widthPct: Number(widthPct.toFixed(2)),
|
||
now: Math.round(el.scrollLeft),
|
||
max: Math.round(max),
|
||
}
|
||
setRail((prev) => (
|
||
prev.visible === next.visible &&
|
||
Math.abs(prev.leftPct - next.leftPct) < 0.1 &&
|
||
Math.abs(prev.widthPct - next.widthPct) < 0.1 &&
|
||
prev.now === next.now &&
|
||
prev.max === next.max
|
||
) ? prev : next)
|
||
}
|
||
|
||
const raf = window.requestAnimationFrame(update)
|
||
el.addEventListener("scroll", update, { passive: true })
|
||
const resizeObserver = new ResizeObserver(update)
|
||
resizeObserver.observe(el)
|
||
const mutationObserver = new MutationObserver(update)
|
||
mutationObserver.observe(el, { childList: true, subtree: true, attributes: true })
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(raf)
|
||
el.removeEventListener("scroll", update)
|
||
resizeObserver.disconnect()
|
||
mutationObserver.disconnect()
|
||
}
|
||
}, [scrollRef])
|
||
|
||
if (!rail.visible) return null
|
||
|
||
const syncScrollFromPointer = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||
const drag = dragRef.current
|
||
const el = scrollRef.current
|
||
if (!drag || !el) return
|
||
const delta = e.clientX - drag.startX
|
||
el.scrollLeft = clamp(
|
||
drag.startScrollLeft + (delta / drag.trackRange) * drag.maxScroll,
|
||
0,
|
||
drag.maxScroll,
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={railRef}
|
||
role="scrollbar"
|
||
aria-label={label}
|
||
aria-orientation="horizontal"
|
||
aria-valuemin={0}
|
||
aria-valuemax={rail.max}
|
||
aria-valuenow={rail.now}
|
||
tabIndex={0}
|
||
className={`thumbnail-scroll-rail nodrag nopan relative mt-2.5 h-9 rounded-full border shadow-[0_14px_28px_rgba(0,0,0,0.34),0_0_0_1px_rgba(255,255,255,0.08)] outline-none transition ${
|
||
dragging
|
||
? "cursor-grabbing border-violet-100/95 bg-violet-500/55 ring-2 ring-violet-100/80"
|
||
: "cursor-grab border-violet-200/70 bg-violet-500/32 hover:border-violet-100/90 hover:bg-violet-400/45 focus-visible:border-violet-100 focus-visible:ring-2 focus-visible:ring-violet-100/80"
|
||
}`}
|
||
onPointerDown={(e) => {
|
||
const el = scrollRef.current
|
||
const track = railRef.current
|
||
if (!el || !track) return
|
||
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth)
|
||
if (maxScroll <= 0) return
|
||
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
|
||
const rect = track.getBoundingClientRect()
|
||
const thumbWidth = rect.width * (rail.widthPct / 100)
|
||
const trackRange = Math.max(1, rect.width - thumbWidth)
|
||
const pointerX = e.clientX - rect.left
|
||
const thumbLeft = (el.scrollLeft / maxScroll) * trackRange
|
||
let startScrollLeft = el.scrollLeft
|
||
|
||
if (pointerX < thumbLeft || pointerX > thumbLeft + thumbWidth) {
|
||
startScrollLeft = clamp(((pointerX - thumbWidth / 2) / trackRange) * maxScroll, 0, maxScroll)
|
||
el.scrollLeft = startScrollLeft
|
||
}
|
||
|
||
dragRef.current = {
|
||
pointerId: e.pointerId,
|
||
startX: e.clientX,
|
||
startScrollLeft,
|
||
maxScroll,
|
||
trackRange,
|
||
}
|
||
setDragging(true)
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
}}
|
||
onPointerMove={(e) => {
|
||
if (dragRef.current?.pointerId !== e.pointerId) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
syncScrollFromPointer(e)
|
||
}}
|
||
onPointerUp={(e) => {
|
||
if (dragRef.current?.pointerId !== e.pointerId) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
dragRef.current = null
|
||
setDragging(false)
|
||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||
}}
|
||
onPointerCancel={(e) => {
|
||
if (dragRef.current?.pointerId !== e.pointerId) return
|
||
dragRef.current = null
|
||
setDragging(false)
|
||
}}
|
||
onKeyDown={(e) => {
|
||
const el = scrollRef.current
|
||
if (!el) return
|
||
const page = Math.max(80, el.clientWidth * 0.65)
|
||
const small = Math.max(32, el.clientWidth * 0.18)
|
||
let next: number | null = null
|
||
if (e.key === "ArrowLeft") next = el.scrollLeft - small
|
||
if (e.key === "ArrowRight") next = el.scrollLeft + small
|
||
if (e.key === "PageUp") next = el.scrollLeft - page
|
||
if (e.key === "PageDown") next = el.scrollLeft + page
|
||
if (e.key === "Home") next = 0
|
||
if (e.key === "End") next = rail.max
|
||
if (next === null) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
el.scrollTo({ left: clamp(next, 0, rail.max), behavior: "smooth" })
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute bottom-[6px] top-[6px] rounded-full bg-white shadow-[0_0_0_1px_rgba(255,255,255,0.78),0_0_24px_rgba(216,180,254,0.86)] transition-colors"
|
||
style={{ left: `${rail.leftPct}%`, width: `${rail.widthPct}%` }}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FloatingThumbnailStrip({
|
||
children,
|
||
label,
|
||
}: {
|
||
children: ReactNode
|
||
label?: string
|
||
}) {
|
||
const scrollRef = useRef<HTMLDivElement>(null)
|
||
|
||
return (
|
||
<div className="absolute left-0 right-0" style={{ bottom: "calc(100% + 12px)" }}>
|
||
<div ref={scrollRef} className="thumbnail-strip flex items-end gap-1.5 overflow-x-auto">
|
||
{children}
|
||
</div>
|
||
<ThumbnailScrollRail scrollRef={scrollRef} label={label} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
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 [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
|
||
|
||
// 点击 input 节点外的任何位置 → 取消 pin(capture 阶段,避免 ReactFlow pane 拦截)
|
||
useEffect(() => {
|
||
if (pinnedPreviewJob === null) return
|
||
const handler = (e: MouseEvent) => {
|
||
const t = e.target as HTMLElement
|
||
if (t.closest('.react-flow__node[data-id="input"]')) return
|
||
setPinnedPreviewJob(null)
|
||
}
|
||
document.addEventListener("mousedown", handler, true)
|
||
return () => document.removeEventListener("mousedown", handler, true)
|
||
}, [pinnedPreviewJob])
|
||
|
||
// 是否已下载 → 显示视频 + 解析按钮
|
||
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 ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。
|
||
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
|
||
{!videoExpanded && d.jobs.length > 0 && (
|
||
<FloatingThumbnailStrip label="输入视频缩略图横向滑动条">
|
||
{/* + 再上传一个(放在最前面) */}
|
||
<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: 96, height: THUMBNAIL_HEIGHT }}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</button>
|
||
{[...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 (
|
||
<div
|
||
key={j.id}
|
||
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: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
|
||
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||
onMouseLeave={() => setHoverPreviewJob(null)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
// 单击:钉住 / 取消钉住大预览 + 切换 active(若需要)
|
||
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
|
||
setPinnedPreviewJob((prev) => (prev?.id === j.id ? null : { id: j.id, ...anchor }))
|
||
if (!isActive && ready) d.onSwitchJob(j.id)
|
||
}}
|
||
onDoubleClick={(e) => {
|
||
e.stopPropagation()
|
||
if (ready) setVideoExpanded(true)
|
||
}}
|
||
title={ready ? `${j.width}×${j.height} · ${j.duration.toFixed(1)}s · 单击钉住大预览 · 双击展开播放` : "下载中…"}
|
||
className="absolute inset-0 w-full h-full overflow-hidden rounded-md"
|
||
>
|
||
{ready ? (
|
||
<video
|
||
src={videoUrl(j.id)}
|
||
muted
|
||
loop
|
||
playsInline
|
||
preload="metadata"
|
||
poster=""
|
||
className="block w-full h-full object-cover bg-black"
|
||
/>
|
||
) : (
|
||
<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>
|
||
{d.onDeleteJob && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (confirm(`删除视频任务 ${j.id.slice(0, 8)}?源视频、关键帧、元素提取图和生成视频都会一并删除。`)) {
|
||
d.onDeleteJob?.(j.id)
|
||
}
|
||
}}
|
||
title="删除这个输入视频"
|
||
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"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</FloatingThumbnailStrip>
|
||
)}
|
||
|
||
{(() => {
|
||
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}
|
||
anchorY={anchor.y}
|
||
pinned={!!pinnedPreviewJob}
|
||
onClose={() => setPinnedPreviewJob(null)}
|
||
/>
|
||
)
|
||
})()}
|
||
|
||
{/* 展开态 — 稍微放大(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}
|
||
pinned={d.pinnedNodes?.has("input")}
|
||
onTogglePin={() => d.onToggleNodePin?.("input")}
|
||
>
|
||
{/* 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
|
||
|
||
type ElementPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number }
|
||
|
||
function collectElementCrops(job: Job | null): ElementPreview[] {
|
||
return 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,
|
||
timestamp: f.timestamp,
|
||
}
|
||
})
|
||
.filter((p) => p.src),
|
||
)
|
||
: []
|
||
}
|
||
|
||
function videoModelLabel(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"
|
||
}
|
||
|
||
/* ============================================================
|
||
4. VisualLabNode — 合并镜头拆解 / 元素改造 / 生视频展示入口
|
||
============================================================ */
|
||
export function VisualLabNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const job = d.job
|
||
const frames = job?.frames ?? []
|
||
const videos = job?.generated_videos ?? []
|
||
const jobId = job?.id
|
||
const aspect = job && (job.width ?? 0) > 0 && (job.height ?? 0) > 0
|
||
? `${job.width}/${job.height}`
|
||
: "9/16"
|
||
const elementCrops = collectElementCrops(job)
|
||
const cleanedCount = frames.filter((x) => x.cleaned_url).length
|
||
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
|
||
const runningVideo = videos.some((v) => v.status === "queued" || v.status === "in_progress")
|
||
const completedVideos = videos.filter((v) => v.status === "completed" && v.url)
|
||
const failedVideo = videos.some((v) => v.status === "failed")
|
||
const status: NodeStatus = runningVideo
|
||
? "running"
|
||
: failedVideo
|
||
? "failed"
|
||
: frames.length > 0 || elementCrops.length > 0 || completedVideos.length > 0
|
||
? "done"
|
||
: keyframeStatus(job)
|
||
|
||
type VisualPreview =
|
||
| { id: string; kind: "frame"; frameIdx: number; src: string; label: string; caption: string; borderClass: string }
|
||
| { id: string; kind: "cutout"; frameIdx: number; elementId: string; cutoutId: string; src: string; label: string; caption: string; borderClass: string }
|
||
| { id: string; kind: "video"; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string }
|
||
|
||
const [hoverPreview, setHoverPreview] = useState<PreviewAnchor<string> | null>(null)
|
||
const [pinnedPreview, setPinnedPreview] = useState<PreviewAnchor<string> | null>(null)
|
||
const rootRef = useRef<HTMLDivElement>(null)
|
||
|
||
const previews: VisualPreview[] = [
|
||
...(job && jobId ? frames.map((f) => ({
|
||
id: `frame:${f.index}`,
|
||
kind: "frame" as const,
|
||
frameIdx: f.index,
|
||
src: effectiveFrameUrl(jobId, f),
|
||
label: `分镜 ${f.index + 1}`,
|
||
caption: `${f.timestamp.toFixed(2)}s`,
|
||
borderClass: "border-orange-300/50",
|
||
})) : []),
|
||
...elementCrops.map((p) => ({
|
||
id: `cutout:${p.frameIdx}:${p.elementId}:${p.cid}`,
|
||
kind: "cutout" as const,
|
||
frameIdx: p.frameIdx,
|
||
elementId: p.elementId,
|
||
cutoutId: p.cid,
|
||
src: p.src,
|
||
label: p.name,
|
||
caption: `分镜 ${p.frameIdx + 1}`,
|
||
borderClass: "border-violet-300/60",
|
||
})),
|
||
...videos.map((v, i) => {
|
||
const videoSrc = apiAssetUrl(v.url)
|
||
const posterSrc = apiAssetUrl(v.poster_url)
|
||
return {
|
||
id: `video:${v.id}`,
|
||
kind: "video" as const,
|
||
videoId: v.id,
|
||
videoSrc: v.status === "completed" && videoSrc ? videoSrc : undefined,
|
||
posterSrc: posterSrc || undefined,
|
||
label: `视频 ${i + 1}`,
|
||
caption: `${videoModelLabel(v.model)} · ${v.status}`,
|
||
borderClass: v.status === "completed" ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55",
|
||
}
|
||
}),
|
||
]
|
||
|
||
useEffect(() => {
|
||
if (pinnedPreview === null) return
|
||
const handler = (e: MouseEvent) => {
|
||
const t = e.target as HTMLElement
|
||
if (t.closest('.react-flow__node[data-id="visual"]')) return
|
||
setPinnedPreview(null)
|
||
}
|
||
document.addEventListener("mousedown", handler, true)
|
||
return () => document.removeEventListener("mousedown", handler, true)
|
||
}, [pinnedPreview])
|
||
|
||
const openFirstFrame = () => {
|
||
const idx = frames[0]?.index
|
||
if (typeof idx === "number") (d.onOpenFramePanel ?? d.onExpandFrame)(idx)
|
||
}
|
||
|
||
return (
|
||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{previews.length > 0 && (
|
||
<FloatingThumbnailStrip label="画面工作台缩略图横向滑动条">
|
||
{previews.map((p) => {
|
||
const isSelected = p.kind !== "video" && d.selectedFrames.has(p.frameIdx)
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 ${
|
||
p.kind === "frame"
|
||
? 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: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
|
||
onMouseEnter={(e) => setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||
onMouseLeave={() => setHoverPreview(null)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
|
||
setPinnedPreview((prev) => (prev?.id === p.id ? null : { id: p.id, ...anchor }))
|
||
if (p.kind === "frame") {
|
||
;(d.onOpenFramePanel ?? d.onExpandFrame)(p.frameIdx)
|
||
} else if (p.kind === "cutout") {
|
||
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
|
||
d.onOpenStoryboard?.(p.frameIdx)
|
||
d.onOpenWorkbench?.(p.frameIdx)
|
||
} else {
|
||
const video = videos.find((v) => v.id === p.videoId)
|
||
if (video) {
|
||
void navigator.clipboard?.writeText(video.prompt).catch(() => {})
|
||
toast.success("已复制视频 prompt")
|
||
}
|
||
}
|
||
}}
|
||
title={`${p.label} · 单击钉住预览${p.kind === "frame" ? " / 打开镜头处理" : p.kind === "cutout" ? " / 进入分镜编排" : " / 复制 prompt"}`}
|
||
className="absolute inset-0 h-full w-full overflow-hidden rounded-md"
|
||
>
|
||
{p.kind === "video" ? (
|
||
p.videoSrc ? (
|
||
<video src={p.videoSrc} poster={p.posterSrc} muted loop playsInline preload="metadata" className="absolute inset-0 h-full w-full object-cover" />
|
||
) : p.posterSrc ? (
|
||
<img src={p.posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
|
||
) : (
|
||
<div className="absolute inset-0 bg-violet-950/50" />
|
||
)
|
||
) : (
|
||
<img src={p.src} alt={p.label} className={`absolute inset-0 h-full w-full ${p.kind === "cutout" ? "object-contain" : "object-cover"}`} />
|
||
)}
|
||
{p.kind === "frame" && isSelected && (
|
||
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
|
||
)}
|
||
<div className="absolute bottom-0 right-0 bg-black/70 px-1 py-0.5 text-[8.5px] font-mono leading-none text-white rounded-bl rounded-br-md">
|
||
{p.kind === "frame" ? p.caption.replace("s", "") + "s" : p.kind === "cutout" ? "元素" : "视频"}
|
||
</div>
|
||
</button>
|
||
|
||
{p.kind === "frame" && d.onCopyImage && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
d.onCopyImage?.({ kind: "keyframe", frame_idx: p.frameIdx, label: `${p.label} 关键帧` })
|
||
}}
|
||
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"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
{p.kind === "cutout" && d.onCopyImage && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
d.onCopyImage?.({
|
||
kind: "cutout",
|
||
frame_idx: p.frameIdx,
|
||
element_id: p.elementId,
|
||
cutout_id: p.cutoutId,
|
||
label: p.label,
|
||
})
|
||
}}
|
||
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"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
{p.kind === "video" && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const video = videos.find((v) => v.id === p.videoId)
|
||
if (!video) return
|
||
void navigator.clipboard?.writeText(video.prompt).catch(() => {})
|
||
toast.success("已复制视频 prompt")
|
||
}}
|
||
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"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
{p.kind === "frame" && d.onDeleteFrame && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (confirm(`删除${p.label}?相关清洗 / 抠图 / 生成图都会一并清除。`)) d.onDeleteFrame?.(p.frameIdx)
|
||
}}
|
||
title="删除该关键帧"
|
||
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"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
{p.kind === "cutout" && d.onDeleteCutout && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (confirm(`删除元素提取图「${p.label}」?该 cutout 文件会被移除。`)) {
|
||
d.onDeleteCutout?.(p.frameIdx, p.elementId, p.cutoutId)
|
||
}
|
||
}}
|
||
title="删除该提取图"
|
||
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"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
{p.kind === "video" && d.onDeleteVideo && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
d.onDeleteVideo?.(p.videoId)
|
||
}}
|
||
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="删除这个视频任务"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</FloatingThumbnailStrip>
|
||
)}
|
||
|
||
{(() => {
|
||
const anchor = pinnedPreview ?? hoverPreview
|
||
if (!anchor) return null
|
||
const p = previews.find((x) => x.id === anchor.id)
|
||
if (!p) return null
|
||
return (
|
||
<HoverPreview
|
||
imgSrc={p.kind === "video" ? p.posterSrc : p.src}
|
||
videoSrc={p.kind === "video" ? p.videoSrc : undefined}
|
||
poster={p.kind === "video" ? p.posterSrc : undefined}
|
||
aspect={aspect}
|
||
label={p.label}
|
||
caption={p.caption}
|
||
borderClass={p.borderClass}
|
||
visible={!!hoverPreview && !pinnedPreview}
|
||
anchorX={anchor.x}
|
||
anchorY={anchor.y}
|
||
pinned={!!pinnedPreview}
|
||
onClose={() => setPinnedPreview(null)}
|
||
/>
|
||
)
|
||
})()}
|
||
|
||
<NodeShell
|
||
type="ai"
|
||
status={status}
|
||
icon={<LayoutGrid className="h-4 w-4" />}
|
||
title="画面工作台 · Visual Lab"
|
||
subtitle={`STEP 2-7 · ${frames.length ? `${frames.length} 帧` : "等待解析"}${videos.length ? ` · ${videos.length} 视频` : ""}`}
|
||
selected={selected}
|
||
pinned={d.pinnedNodes?.has("visual")}
|
||
onTogglePin={() => d.onToggleNodePin?.("visual")}
|
||
>
|
||
<div className="grid grid-cols-3 gap-2 text-[10.5px] text-[var(--text-soft)]">
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
|
||
disabled={frames.length === 0}
|
||
className="rounded-md border border-white/10 px-2 py-1.5 text-left transition hover:border-orange-300/50 hover:bg-orange-400/10 disabled:opacity-35"
|
||
title="打开镜头处理面板"
|
||
>
|
||
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{frames.length}</div>
|
||
<div>关键帧</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.(frames.find((f) => d.selectedFrames.has(f.index))?.index ?? frames[0]?.index) }}
|
||
disabled={!job || frames.length === 0}
|
||
className="rounded-md border border-white/10 px-2 py-1.5 text-left transition hover:border-violet-300/50 hover:bg-violet-400/10 disabled:opacity-35"
|
||
title="进入分镜编排"
|
||
>
|
||
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{elementCrops.length}</div>
|
||
<div>元素 / 编排</div>
|
||
</button>
|
||
<div className="rounded-md border border-white/10 px-2 py-1.5">
|
||
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{videos.length}</div>
|
||
<div>视频任务</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 text-[10.5px] leading-snug text-[var(--text-faint)]">
|
||
{frames.length > 0 ? (
|
||
<>
|
||
{cleanedCount} 已清洗 · {cutoutCount} 已抠图 · {d.selectedFrames.size}/{frames.length} 入编排 · {completedVideos.length} 已完成
|
||
</>
|
||
) : (
|
||
"解析后这里展示关键帧、元素和视频任务;具体处理仍在点击后的工作台完成。"
|
||
)}
|
||
</div>
|
||
</NodeShell>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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"
|
||
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(() => {
|
||
if (pinnedPreviewFrame === null) return
|
||
const handler = (e: MouseEvent) => {
|
||
const t = e.target as HTMLElement
|
||
if (t.closest('.react-flow__node[data-id="keyframe"]')) return
|
||
setPinnedPreviewFrame(null)
|
||
}
|
||
document.addEventListener("mousedown", handler, true)
|
||
return () => document.removeEventListener("mousedown", handler, true)
|
||
}, [pinnedPreviewFrame])
|
||
|
||
return (
|
||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */}
|
||
{frames.length > 0 && jobId && (
|
||
<FloatingThumbnailStrip label="关键帧缩略图横向滑动条">
|
||
{frames.map((f) => {
|
||
const isSel = d.selectedFrames.has(f.index)
|
||
return (
|
||
<div
|
||
key={f.index}
|
||
className={`group relative shrink-0 rounded-md border overflow-visible 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={{
|
||
height: THUMBNAIL_HEIGHT,
|
||
aspectRatio: d.job && d.job.height > 0
|
||
? `${d.job.width}/${d.job.height}`
|
||
: "16/9",
|
||
}}
|
||
onMouseEnter={(e) => setHoverPreviewFrame({ id: f.index, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||
onMouseLeave={() => setHoverPreviewFrame(null)}
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
|
||
setPinnedPreviewFrame((prev) => (prev?.id === f.index ? null : { id: f.index, ...anchor }))
|
||
;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index)
|
||
}}
|
||
title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 单击钉住大预览 / 打开详情面板`}
|
||
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.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"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
{/* 删除按钮:常驻可见 */}
|
||
{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.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"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</FloatingThumbnailStrip>
|
||
)}
|
||
|
||
{(() => {
|
||
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}
|
||
anchorY={anchor.y}
|
||
pinned={!!pinnedPreviewFrame}
|
||
onClose={() => setPinnedPreviewFrame(null)}
|
||
/>
|
||
)
|
||
})()}
|
||
|
||
<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}
|
||
pinned={d.pinnedNodes?.has("keyframe")}
|
||
onTogglePin={() => d.onToggleNodePin?.("keyframe")}
|
||
>
|
||
{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}
|
||
pinned={d.pinnedNodes?.has("asr")}
|
||
onTogglePin={() => d.onToggleNodePin?.("asr")}
|
||
>
|
||
<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}
|
||
pinned={d.pinnedNodes?.has("translate")}
|
||
onTogglePin={() => d.onToggleNodePin?.("translate")}
|
||
>
|
||
<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({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
return (
|
||
<NodeShell
|
||
type="ai" status="pending"
|
||
icon={<FileEdit className="h-4 w-4" />}
|
||
title="产品文案 · Rewrite"
|
||
subtitle="STEP 5 · 接 SKG 卖点"
|
||
selected={selected}
|
||
pinned={d.pinnedNodes?.has("rewrite")}
|
||
onTogglePin={() => d.onToggleNodePin?.("rewrite")}
|
||
>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
5b. AudioNode — 合并 ASR + 翻译 + 改写为一个"音频处理"节点(占位卡片,无填充)
|
||
============================================================ */
|
||
export function AudioNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const job = d.job
|
||
const transcript = job?.transcript ?? []
|
||
const hasASR = transcript.length > 0
|
||
const status: NodeStatus = !job
|
||
? "pending"
|
||
: job.status === "transcribing"
|
||
? "running"
|
||
: hasASR
|
||
? "done"
|
||
: "pending"
|
||
return (
|
||
<NodeShell
|
||
type="ai" status={status}
|
||
icon={<Mic className="h-4 w-4" />}
|
||
title="音频处理 · Audio"
|
||
subtitle={hasASR ? `STEP 3 · ${transcript.length} 段` : "STEP 3"}
|
||
selected={selected}
|
||
pinned={d.pinnedNodes?.has("audio")}
|
||
onTogglePin={() => d.onToggleNodePin?.("audio")}
|
||
>
|
||
<div className="text-[11px] text-[var(--text-soft)] leading-snug">
|
||
音轨 → ASR 转录 → 英中翻译 → 接 SKG 卖点改写文案<br />
|
||
<span className="text-[var(--text-faint)] font-mono">Gemini 2.5 Flash</span>
|
||
</div>
|
||
</NodeShell>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
6. StoryboardNode — 元素改造 + 分镜编排入口
|
||
============================================================ */
|
||
const IMAGEGEN_WIDTH = 360
|
||
|
||
export function StoryboardNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const job = d?.job
|
||
const [hoverPreviewCutout, setHoverPreviewCutout] = useState<PreviewAnchor<string> | null>(null)
|
||
const [pinnedPreviewCutout, setPinnedPreviewCutout] = useState<PreviewAnchor<string> | null>(null)
|
||
const rootRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 点击 storyboard 节点外 → 取消 pin
|
||
useEffect(() => {
|
||
if (pinnedPreviewCutout === null) return
|
||
const handler = (e: MouseEvent) => {
|
||
const t = e.target as HTMLElement
|
||
if (t.closest('.react-flow__node[data-id="storyboard"]')) return
|
||
setPinnedPreviewCutout(null)
|
||
}
|
||
document.addEventListener("mousedown", handler, true)
|
||
return () => document.removeEventListener("mousedown", handler, true)
|
||
}, [pinnedPreviewCutout])
|
||
|
||
// 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材)
|
||
type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: 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,
|
||
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 ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{/* 节点上方:所有元素 crop 图(编排输入素材)— 视觉类节点统一样板:单行横滚 + 左上复制 + 右上删除 + hover/click pin 大预览 */}
|
||
{elementCrops.length > 0 && job && (
|
||
<FloatingThumbnailStrip label="元素缩略图横向滑动条">
|
||
{elementCrops.map((p) => {
|
||
const key = `${p.frameIdx}_${p.elementId}`
|
||
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: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
|
||
onMouseEnter={(e) => setHoverPreviewCutout({ id: key, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||
onMouseLeave={() => setHoverPreviewCutout(null)}
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
|
||
setPinnedPreviewCutout((prev) => (prev?.id === key ? null : { id: key, ...anchor }))
|
||
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.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"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
{/* 右上:删除 */}
|
||
{d.onDeleteCutout && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (confirm(`删除元素提取图「${p.name}」?该 cutout 文件会被移除。`)) {
|
||
d.onDeleteCutout?.(p.frameIdx, p.elementId, p.cid)
|
||
}
|
||
}}
|
||
title="删除该提取图"
|
||
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"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</FloatingThumbnailStrip>
|
||
)}
|
||
|
||
{(() => {
|
||
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}
|
||
anchorY={anchor.y}
|
||
pinned={!!pinnedPreviewCutout}
|
||
onClose={() => setPinnedPreviewCutout(null)}
|
||
/>
|
||
)
|
||
})()}
|
||
|
||
<NodeShell
|
||
type="ai" status={status}
|
||
icon={<LayoutGrid className="h-4 w-4" />}
|
||
title="元素改造 · Storyboard"
|
||
subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
|
||
selected={selected}
|
||
pinned={d.pinnedNodes?.has("storyboard")}
|
||
onTogglePin={() => d.onToggleNodePin?.("storyboard")}
|
||
>
|
||
<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 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")
|
||
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"
|
||
}
|
||
return (
|
||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{videos.length > 0 && (
|
||
<FloatingThumbnailStrip label="生成视频缩略图横向滑动条">
|
||
{videos.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 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: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
|
||
onMouseEnter={(e) => setHoverPreviewVideo({ id: v.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||
onMouseLeave={() => setHoverPreviewVideo(null)}
|
||
>
|
||
<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"
|
||
/>
|
||
) : 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.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"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
d.onDeleteVideo?.(v.id)
|
||
}}
|
||
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="删除这个视频任务"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)})}
|
||
</FloatingThumbnailStrip>
|
||
)}
|
||
{(() => {
|
||
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}
|
||
anchorY={hoverPreviewVideo.y}
|
||
/>
|
||
)
|
||
})()}
|
||
<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}
|
||
pinned={d.pinnedNodes?.has("videogen")}
|
||
onTogglePin={() => d.onToggleNodePin?.("videogen")}
|
||
>
|
||
<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({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
return (
|
||
<NodeShell
|
||
type="output" status="pending"
|
||
icon={<FileVideo className="h-4 w-4" />}
|
||
title="合成成品 · Compose"
|
||
subtitle="STEP 8 · ffmpeg + 字幕"
|
||
selected={selected}
|
||
hasSource={false}
|
||
pinned={d.pinnedNodes?.has("compose")}
|
||
onTogglePin={() => d.onToggleNodePin?.("compose")}
|
||
>
|
||
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
|
||
视频片段 + 字幕 / TTS<br />→ 最终 mp4 输出
|
||
</div>
|
||
</NodeShell>
|
||
)
|
||
}
|