2576 lines
113 KiB
TypeScript
2576 lines
113 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, Maximize2,
|
||
Copy, Trash2, Move, PanelLeft, PanelRight, PanelBottom, ChevronLeft, ChevronRight, SlidersHorizontal,
|
||
CheckCircle2, AlertTriangle, Sparkles, Package, PlayCircle, RotateCcw,
|
||
} 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, type ProductFusionShot, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
|
||
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
||
} from "@/lib/api"
|
||
import { FrameLightbox } from "@/components/lightbox"
|
||
|
||
export type CanvasPanelDock = "canvas" | "left" | "right" | "bottom"
|
||
|
||
export interface NodeData {
|
||
job: Job | null // 当前 active job
|
||
jobs: Job[] // 所有 job 列表
|
||
activeJobId: string | null
|
||
submitting: boolean
|
||
analyzing: boolean
|
||
frameTargets: Record<string, FrameExtractTarget>
|
||
frameCounts: Record<string, number>
|
||
frameQualities: Record<string, FrameExtractQuality>
|
||
selectedFrames: Set<number>
|
||
expandedFrame: number | null
|
||
framePanelScale?: number
|
||
framePanelPinned?: boolean
|
||
framePanelDock?: CanvasPanelDock
|
||
videoPanelJobId?: string | null
|
||
videoPanelScale?: number
|
||
videoPanelDock?: CanvasPanelDock
|
||
onSubmitUrl: (url: string) => void
|
||
onUploadFile: (file: File) => void
|
||
onAnalyze: (options?: { mode?: FrameExtractMode }) => void
|
||
onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void
|
||
onFrameTargetChange: (jobId: string, target: FrameExtractTarget) => void
|
||
onFrameCountChange: (jobId: string, count: number) => void
|
||
onFrameQualityChange: (jobId: string, quality: FrameExtractQuality) => void
|
||
onToggleFrame: (idx: number) => void
|
||
onExpandFrame: (idx: number) => void
|
||
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
|
||
onFramePanelScaleChange?: (scale: number) => void
|
||
onFramePanelPinnedChange?: (pinned: boolean) => void
|
||
onFramePanelDockChange?: (dock: CanvasPanelDock) => void
|
||
onCloseExpandedFrame: () => void
|
||
onAddManualFrame: (t: number) => void
|
||
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<void> | void
|
||
onOpenVideoPanel?: (jobId: string) => void
|
||
onCloseVideoPanel?: () => void
|
||
onVideoPanelScaleChange?: (scale: number) => void
|
||
onVideoPanelDockChange?: (dock: CanvasPanelDock) => void
|
||
onSwitchJob: (id: string) => void
|
||
onJobUpdate: (j: Job) => void
|
||
onDeleteJob?: (id: string) => void
|
||
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
|
||
onDeleteFrame?: (idx: number) => void // 删整张关键帧
|
||
onDeleteFrameForJob?: (jobId: string, idx: number) => Promise<void> | 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 // 展开顶部分镜编排内嵌面板
|
||
clipboard?: ImageRef | null
|
||
onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽)
|
||
onGenerateProductFusionVideo?: (frameIdx: number, shot: ProductFusionShot) => Promise<void> | void
|
||
onTranscribeAudio?: (jobId?: string) => Promise<void> | 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 = 192
|
||
const FLOATING_PANEL_EDGE_INSET = 8
|
||
const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hint: string }> = [
|
||
{ value: "transparent_human", label: "透明骨架人", hint: "本地算力筛清晰主体,不逐帧调用 Vision" },
|
||
{ value: "balanced", label: "综合关键帧", hint: "清晰、去重、变化、时间覆盖" },
|
||
{ value: "subject", label: "清晰主体", hint: "人物 / 产品主体更清楚" },
|
||
{ value: "transition", label: "转场变化", hint: "切镜和画面变化优先" },
|
||
{ value: "expression", label: "表情瞬间", hint: "人物 / 动物表情倾向" },
|
||
{ value: "motion", label: "动作峰值", hint: "动作变化更明显" },
|
||
]
|
||
const FRAME_COUNT_OPTIONS = [12, 8, 5, 3]
|
||
const FRAME_QUALITY_OPTIONS: Array<{ value: FrameExtractQuality; label: string; hint: string }> = [
|
||
{ value: "auto", label: "自动", hint: "按电脑性能和视频时长自动选择" },
|
||
{ value: "fast", label: "快速", hint: "2fps / 360px,长视频省电" },
|
||
{ value: "accurate", label: "精细", hint: "8fps / 720px,M2 Max 轻松可用" },
|
||
{ value: "ultra", label: "极准", hint: "12fps / 960px,本机约 3 秒扫描 1 分钟视频" },
|
||
]
|
||
|
||
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,
|
||
toolbar,
|
||
}: {
|
||
children: ReactNode
|
||
label?: string
|
||
toolbar?: ReactNode
|
||
}) {
|
||
const scrollRef = useRef<HTMLDivElement>(null)
|
||
|
||
return (
|
||
<div className="absolute left-0 right-0" style={{ bottom: "calc(100% + 12px)" }}>
|
||
{toolbar && <div className="mb-2">{toolbar}</div>}
|
||
<div ref={scrollRef} className="thumbnail-strip flex items-end gap-1.5 overflow-x-auto">
|
||
{children}
|
||
</div>
|
||
<ThumbnailScrollRail scrollRef={scrollRef} label={label} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DeleteConfirmDialog({
|
||
title,
|
||
description,
|
||
confirmLabel = "删除",
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
title: string
|
||
description: string
|
||
confirmLabel?: string
|
||
onCancel: () => void
|
||
onConfirm: () => void
|
||
}) {
|
||
useEffect(() => {
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") onCancel()
|
||
}
|
||
document.addEventListener("keydown", onKeyDown)
|
||
return () => document.removeEventListener("keydown", onKeyDown)
|
||
}, [onCancel])
|
||
|
||
if (typeof document === "undefined") return null
|
||
|
||
return createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm"
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation()
|
||
if (e.target === e.currentTarget) onCancel()
|
||
}}
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={title}
|
||
className="w-full max-w-sm rounded-xl border border-white/14 bg-zinc-950/95 p-4 text-white shadow-2xl"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/16 text-rose-200">
|
||
<Trash2 className="h-4 w-4" />
|
||
</div>
|
||
<div className="text-sm font-semibold">{title}</div>
|
||
</div>
|
||
<p className="text-xs leading-5 text-white/68">{description}</p>
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onCancel() }}
|
||
className="rounded-md border border-white/12 bg-white/[0.04] px-3 py-1.5 text-xs font-medium text-white/78 transition hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onConfirm() }}
|
||
className="rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white shadow-lg shadow-rose-950/35 transition hover:bg-rose-400"
|
||
>
|
||
{confirmLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
function FrameExtractQuickBar({
|
||
target,
|
||
count,
|
||
quality,
|
||
disabled,
|
||
running,
|
||
hasFrames,
|
||
onTargetChange,
|
||
onCountChange,
|
||
onQualityChange,
|
||
onAnalyze,
|
||
}: {
|
||
target: FrameExtractTarget
|
||
count: number
|
||
quality: FrameExtractQuality
|
||
disabled: boolean
|
||
running: boolean
|
||
hasFrames: boolean
|
||
onTargetChange: (target: FrameExtractTarget) => void
|
||
onCountChange: (count: number) => void
|
||
onQualityChange: (quality: FrameExtractQuality) => void
|
||
onAnalyze: () => void
|
||
}) {
|
||
const option = FRAME_TARGET_OPTIONS.find((item) => item.value === target) ?? FRAME_TARGET_OPTIONS[0]
|
||
const qualityOption = FRAME_QUALITY_OPTIONS.find((item) => item.value === quality) ?? FRAME_QUALITY_OPTIONS[0]
|
||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||
|
||
return (
|
||
<div
|
||
className="nodrag nopan w-full rounded-lg border border-white/16 bg-zinc-950/90 p-1.5 text-white shadow-2xl shadow-violet-950/30 backdrop-blur"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex gap-1">
|
||
<select
|
||
value={target}
|
||
disabled={disabled}
|
||
onChange={(e) => onTargetChange(e.target.value as FrameExtractTarget)}
|
||
className="h-8 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-2 text-[10.5px] font-semibold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
|
||
aria-label="选择自动抽帧目标"
|
||
title={option.hint}
|
||
>
|
||
{FRAME_TARGET_OPTIONS.map((item) => (
|
||
<option key={item.value} value={item.value}>{item.label}</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={onAnalyze}
|
||
title={hasFrames ? "追加自动抽帧" : "自动抽帧"}
|
||
className="inline-flex h-8 shrink-0 items-center justify-center gap-1 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 px-2.5 text-[10.5px] font-bold text-white shadow-lg shadow-violet-950/35 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-45"
|
||
>
|
||
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Scissors className="h-3 w-3" />}
|
||
{running ? "抽取" : hasFrames ? "追加" : "抽帧"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => setSettingsOpen((open) => !open)}
|
||
title={`设置 · ${count} 张 · ${qualityOption.label}`}
|
||
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-white/12 transition disabled:cursor-not-allowed disabled:opacity-45 ${
|
||
settingsOpen ? "bg-white text-violet-700" : "bg-white/[0.06] text-white/75 hover:bg-white/[0.12] hover:text-white"
|
||
}`}
|
||
>
|
||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
{settingsOpen && (
|
||
<div className="mt-1.5 flex gap-1">
|
||
<select
|
||
value={count}
|
||
disabled={disabled}
|
||
onChange={(e) => onCountChange(Number(e.target.value))}
|
||
className="h-7 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-bold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
|
||
aria-label="选择抽帧张数"
|
||
>
|
||
{FRAME_COUNT_OPTIONS.map((item) => (
|
||
<option key={item} value={item}>{item} 张</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={quality}
|
||
disabled={disabled}
|
||
onChange={(e) => onQualityChange(e.target.value as FrameExtractQuality)}
|
||
title={qualityOption.hint}
|
||
className="h-7 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-bold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
|
||
aria-label="选择抽帧精度"
|
||
>
|
||
{FRAME_QUALITY_OPTIONS.map((item) => (
|
||
<option key={item.value} value={item.value}>{item.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
1. InputNode — TK 链接 / 上传
|
||
============================================================ */
|
||
export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) {
|
||
const d: NodeData = data
|
||
const [url, setUrl] = useState("")
|
||
const [hoverPreviewJob, setHoverPreviewJob] = useState<PreviewAnchor<string> | null>(null)
|
||
const [pinnedPreviewJob, setPinnedPreviewJob] = useState<PreviewAnchor<string> | null>(null)
|
||
const [deleteJobTarget, setDeleteJobTarget] = useState<Job | null>(null)
|
||
const rootRef = useRef<HTMLDivElement>(null)
|
||
const fileRef = useRef<HTMLInputElement>(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 hasFrames = (job?.frames.length ?? 0) > 0
|
||
const inputLocked = isDownloading || d.submitting
|
||
|
||
return (
|
||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),每个视频上方绑定独立抽帧快捷条。
|
||
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
|
||
{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"
|
||
const thumbNaturalWidth = ready && j.height ? Math.max(96, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 96
|
||
const toolWidth = Math.max(148, thumbNaturalWidth)
|
||
const target = d.frameTargets[j.id] ?? "transparent_human"
|
||
const count = d.frameCounts[j.id] ?? 12
|
||
const quality = d.frameQualities[j.id] ?? "auto"
|
||
const jHasFrames = j.frames.length > 0
|
||
const jRunning = ["splitting", "transcribing"].includes(j.status)
|
||
return (
|
||
<div
|
||
key={j.id}
|
||
className="group relative flex shrink-0 flex-col gap-1.5"
|
||
style={{ width: toolWidth }}
|
||
>
|
||
{ready ? (
|
||
<FrameExtractQuickBar
|
||
target={target}
|
||
count={count}
|
||
quality={quality}
|
||
disabled={jRunning}
|
||
running={jRunning}
|
||
hasFrames={jHasFrames}
|
||
onTargetChange={(next) => d.onFrameTargetChange(j.id, next)}
|
||
onCountChange={(next) => d.onFrameCountChange(j.id, next)}
|
||
onQualityChange={(next) => d.onFrameQualityChange(j.id, next)}
|
||
onAnalyze={() => d.onAnalyzeJob(j.id, { mode: jHasFrames ? "append" : "replace" })}
|
||
/>
|
||
) : (
|
||
<div className="h-[44px] rounded-lg border border-white/10 bg-white/[0.03]" />
|
||
)}
|
||
<div
|
||
className={`relative self-center 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={{ width: thumbNaturalWidth, 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()
|
||
if (!ready) return
|
||
setPinnedPreviewJob(null)
|
||
if (!isActive) d.onSwitchJob(j.id)
|
||
d.onOpenVideoPanel?.(j.id)
|
||
}}
|
||
onDoubleClick={(e) => {
|
||
e.stopPropagation()
|
||
if (ready) {
|
||
setPinnedPreviewJob(null)
|
||
if (!isActive) d.onSwitchJob(j.id)
|
||
d.onOpenVideoPanel?.(j.id)
|
||
}
|
||
}}
|
||
title={ready ? `${j.width}×${j.height} · ${(j.duration ?? 0).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 ?? 0).toFixed(1)}s` : "…"}
|
||
</div>
|
||
</button>
|
||
{d.onDeleteJob && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setDeleteJobTarget(j)
|
||
}}
|
||
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>
|
||
</div>
|
||
)
|
||
})}
|
||
</FloatingThumbnailStrip>
|
||
)}
|
||
|
||
{deleteJobTarget && (
|
||
<DeleteConfirmDialog
|
||
title="删除这个输入视频?"
|
||
description={`会同时删除 ${deleteJobTarget.id.slice(0, 8)} 的源视频、关键帧、元素提取图、生成视频和本地任务目录。`}
|
||
confirmLabel="删除视频"
|
||
onCancel={() => setDeleteJobTarget(null)}
|
||
onConfirm={() => {
|
||
const id = deleteJobTarget.id
|
||
setDeleteJobTarget(null)
|
||
d.onDeleteJob?.(id)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{(() => {
|
||
const anchor = pinnedPreviewJob ?? hoverPreviewJob
|
||
if (!anchor) 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)}
|
||
/>
|
||
)
|
||
})()}
|
||
|
||
<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>
|
||
|
||
{hasFrames && (
|
||
<div className="mt-2 rounded-md border border-violet-400/20 bg-violet-500/10 px-3 py-2 text-[11px] font-medium text-violet-100">
|
||
已抽 {job.frames.length} 张 · 上方可继续追加
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</NodeShell>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
1b. VideoFramePanelNode — 画布内视频抽帧工作面板
|
||
============================================================ */
|
||
export function VideoFramePanelNode({ data }: any) {
|
||
const d: NodeData = data
|
||
const { getZoom } = useReactFlow()
|
||
const panelJob = d.videoPanelJobId
|
||
? d.jobs.find((j) => j.id === d.videoPanelJobId) ?? null
|
||
: null
|
||
const videoRef = useRef<HTMLVideoElement>(null)
|
||
const scale = d.videoPanelScale ?? 1
|
||
const dock = d.videoPanelDock ?? "canvas"
|
||
const docked = dock !== "canvas"
|
||
const [currentT, setCurrentT] = useState(0)
|
||
const [adding, setAdding] = useState(false)
|
||
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
|
||
|
||
useEffect(() => {
|
||
setCurrentT(0)
|
||
setAdding(false)
|
||
setDeletingFrame(null)
|
||
}, [panelJob?.id])
|
||
|
||
useEffect(() => {
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") d.onCloseVideoPanel?.()
|
||
}
|
||
window.addEventListener("keydown", onKeyDown)
|
||
return () => window.removeEventListener("keydown", onKeyDown)
|
||
}, [d])
|
||
|
||
if (!panelJob?.video_url) return null
|
||
|
||
const panelWidth = Math.round(760 * scale)
|
||
const panelHeight = Math.round(620 * scale)
|
||
const bodyHeight = Math.max(500, panelHeight - 28)
|
||
const duration = panelJob.duration ?? 0
|
||
const frames = [...panelJob.frames].sort((a, b) => a.timestamp - b.timestamp)
|
||
const aspect = panelJob.width && panelJob.height ? `${panelJob.width}/${panelJob.height}` : "9/16"
|
||
const panelTarget = d.frameTargets[panelJob.id] ?? "transparent_human"
|
||
const panelCount = d.frameCounts[panelJob.id] ?? 12
|
||
const panelQuality = d.frameQualities[panelJob.id] ?? "auto"
|
||
const panelRunning = ["splitting", "transcribing"].includes(panelJob.status)
|
||
const dockText: Record<CanvasPanelDock, string> = {
|
||
canvas: "画布模式",
|
||
left: "吸附左侧",
|
||
right: "吸附右侧",
|
||
bottom: "吸附底部",
|
||
}
|
||
|
||
const setScale = (next: number) => {
|
||
const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2))))
|
||
d.onVideoPanelScaleChange?.(clamped)
|
||
}
|
||
|
||
const startResize = (e: ReactPointerEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const startX = e.clientX
|
||
const startY = e.clientY
|
||
const startScale = scale
|
||
const zoom = docked ? 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 / 620
|
||
setScale(startScale + delta)
|
||
}
|
||
const onUp = () => {
|
||
window.removeEventListener("pointermove", onMove)
|
||
window.removeEventListener("pointerup", onUp)
|
||
}
|
||
window.addEventListener("pointermove", onMove)
|
||
window.addEventListener("pointerup", onUp)
|
||
}
|
||
|
||
const seekTo = (next: number) => {
|
||
const t = clamp(next, 0, Math.max(duration, 0))
|
||
setCurrentT(t)
|
||
if (videoRef.current) videoRef.current.currentTime = t
|
||
}
|
||
|
||
const addCurrentFrame = async () => {
|
||
const t = videoRef.current?.currentTime ?? currentT
|
||
setAdding(true)
|
||
try {
|
||
if (d.onAddManualFrameForJob) await d.onAddManualFrameForJob(panelJob.id, t)
|
||
else await d.onAddManualFrame(t)
|
||
} finally {
|
||
setAdding(false)
|
||
}
|
||
}
|
||
|
||
const deleteFrameFromPanel = async (idx: number) => {
|
||
setDeletingFrame(idx)
|
||
try {
|
||
if (d.onDeleteFrameForJob) await d.onDeleteFrameForJob(panelJob.id, idx)
|
||
else await d.onDeleteFrame?.(idx)
|
||
} finally {
|
||
setDeletingFrame(null)
|
||
}
|
||
}
|
||
|
||
const dockButtonClass = (value: CanvasPanelDock) =>
|
||
`nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${
|
||
dock === value
|
||
? "bg-white text-violet-700 shadow"
|
||
: "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white"
|
||
}`
|
||
|
||
const panel = (
|
||
<div
|
||
className="relative overflow-hidden rounded-2xl border border-white/15 bg-black/78 text-white shadow-2xl backdrop-blur-xl"
|
||
style={{
|
||
width: panelWidth,
|
||
height: panelHeight,
|
||
maxWidth: "calc(100vw - 32px)",
|
||
maxHeight: `calc(100vh - ${FLOATING_PANEL_EDGE_INSET * 2}px)`,
|
||
boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)",
|
||
}}
|
||
>
|
||
<div
|
||
className={`video-frame-panel-drag flex h-7 items-center justify-between px-3 text-white ${docked ? "cursor-default" : "cursor-move"}`}
|
||
style={{ background: "var(--grad-input)" }}
|
||
>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<FileVideo className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="truncate text-[12px] font-semibold">视频抽帧 · Input</span>
|
||
<span className="shrink-0 text-[10px] font-mono text-white/65">
|
||
{panelJob.width}×{panelJob.height} · {duration.toFixed(1)}s
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="mr-1 text-[10px] text-white/60">
|
||
{dockText[dock]}
|
||
</span>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("canvas") }} className={dockButtonClass("canvas")} title="回到画布模式">
|
||
<Move className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("left") }} className={dockButtonClass("left")} title="吸附到左侧">
|
||
<PanelLeft className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("right") }} className={dockButtonClass("right")} title="吸附到右侧">
|
||
<PanelRight className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("bottom") }} className={dockButtonClass("bottom")} title="吸附到底部">
|
||
<PanelBottom className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none" title="缩小面板">
|
||
-
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); setScale(1) }} className="nodrag h-6 min-w-10 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-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none" title="放大面板">
|
||
+
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onCloseVideoPanel?.() }} className="nodrag h-6 w-6 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.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="nodrag nowheel grid gap-3 p-3 lg:grid-cols-[minmax(260px,0.8fr)_minmax(280px,1fr)]" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
|
||
<div className="flex min-h-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black">
|
||
<video
|
||
ref={videoRef}
|
||
src={videoUrl(panelJob.id)}
|
||
controls
|
||
autoPlay
|
||
muted
|
||
playsInline
|
||
preload="auto"
|
||
onTimeUpdate={(e) => setCurrentT((e.target as HTMLVideoElement).currentTime)}
|
||
className="max-h-full max-w-full bg-black"
|
||
style={{ aspectRatio: aspect }}
|
||
/>
|
||
</div>
|
||
<div className="flex min-h-0 flex-col gap-3 overflow-hidden">
|
||
<FrameExtractQuickBar
|
||
target={panelTarget}
|
||
count={panelCount}
|
||
quality={panelQuality}
|
||
disabled={panelRunning}
|
||
running={panelRunning}
|
||
hasFrames={frames.length > 0}
|
||
onTargetChange={(next) => d.onFrameTargetChange(panelJob.id, next)}
|
||
onCountChange={(next) => d.onFrameCountChange(panelJob.id, next)}
|
||
onQualityChange={(next) => d.onFrameQualityChange(panelJob.id, next)}
|
||
onAnalyze={() => d.onAnalyzeJob(panelJob.id, { mode: frames.length > 0 ? "append" : "replace" })}
|
||
/>
|
||
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<div className="font-mono text-[12px] text-white/72">
|
||
当前 <span className="text-white">{currentT.toFixed(2)}s</span>
|
||
</div>
|
||
<div className="font-mono text-[11px] text-white/45">
|
||
{frames.length} 张关键帧
|
||
</div>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={Math.max(duration, 0.1)}
|
||
step={0.01}
|
||
value={clamp(currentT, 0, Math.max(duration, 0.1))}
|
||
onChange={(e) => seekTo(Number(e.target.value))}
|
||
className="w-full accent-violet-400"
|
||
aria-label="视频时间轴"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={adding}
|
||
onClick={(e) => { e.stopPropagation(); void addCurrentFrame() }}
|
||
className="mt-3 w-full rounded-lg bg-emerald-500 px-4 py-2.5 text-[13px] font-semibold text-white shadow-lg shadow-emerald-950/35 transition hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-55 inline-flex items-center justify-center gap-2"
|
||
>
|
||
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||
{adding ? "抽帧中…" : `把 ${currentT.toFixed(1)}s 加为关键帧`}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-hidden rounded-xl border border-white/10 bg-white/[0.035] p-3">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<div className="text-[12px] font-semibold text-white/85">已抽关键帧</div>
|
||
<div className="text-[10px] font-mono text-white/40">点击缩略图定位时间</div>
|
||
</div>
|
||
{frames.length > 0 ? (
|
||
<div className="grid max-h-full grid-cols-3 gap-2 overflow-y-auto pr-1">
|
||
{frames.map((f) => (
|
||
<div
|
||
key={f.index}
|
||
className="group relative overflow-hidden rounded-md border border-white/10 bg-black text-left transition hover:border-violet-300/70"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); seekTo(f.timestamp) }}
|
||
className="block w-full text-left"
|
||
title={`跳到 ${f.timestamp.toFixed(2)}s`}
|
||
>
|
||
<img src={effectiveFrameUrl(panelJob.id, f)} alt="" className="aspect-[9/16] w-full object-cover opacity-90 transition group-hover:opacity-100" />
|
||
<div className="px-1.5 py-1 font-mono text-[9.5px] text-white/65">
|
||
{f.timestamp.toFixed(1)}s
|
||
</div>
|
||
</button>
|
||
{(d.onDeleteFrameForJob || d.onDeleteFrame) && (
|
||
<button
|
||
type="button"
|
||
disabled={deletingFrame === f.index}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
void deleteFrameFromPanel(f.index)
|
||
}}
|
||
className="absolute right-1 top-1 z-10 inline-flex h-6 w-6 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400 disabled:cursor-wait disabled:opacity-70"
|
||
title={`删除 ${f.timestamp.toFixed(1)}s 关键帧`}
|
||
aria-label={`删除 ${f.timestamp.toFixed(1)}s 关键帧`}
|
||
>
|
||
{deletingFrame === f.index ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex h-full min-h-32 items-center justify-center rounded-lg border border-dashed border-white/10 text-[12px] text-white/35">
|
||
还没有关键帧,拖动时间轴后点击抽帧。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</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-violet-400/35 hover:text-white inline-flex items-center justify-center"
|
||
title="拖动右下角缩放面板"
|
||
>
|
||
<Maximize2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)
|
||
|
||
if (docked && typeof document !== "undefined") {
|
||
const fixedStyle =
|
||
dock === "left"
|
||
? { left: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
|
||
: dock === "right"
|
||
? { right: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
|
||
: { left: "50%", bottom: FLOATING_PANEL_EDGE_INSET, transform: "translateX(-50%)" }
|
||
return createPortal(
|
||
<div className="fixed z-[240]" style={fixedStyle}>
|
||
{panel}
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
return panel
|
||
}
|
||
|
||
/* ============================================================
|
||
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 }
|
||
type SceneAssetPreview = { frameIdx: number; assetId: string; label: string; src: string; width: number; height: number; risk?: string }
|
||
type SubjectAssetPreview = { frameIdx: number; elementId: string; assetId: string; label: string; src: string; width: number; height: number; view: string }
|
||
|
||
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 collectSceneAssets(job: Job | null): SceneAssetPreview[] {
|
||
return job
|
||
? job.frames.flatMap((f) =>
|
||
(f.scene_assets ?? []).map((asset) => ({
|
||
frameIdx: f.index,
|
||
assetId: asset.id,
|
||
label: asset.label || `分镜 ${f.index + 1} 场景图`,
|
||
src: apiAssetUrl(asset.url),
|
||
width: asset.width,
|
||
height: asset.height,
|
||
risk: asset.quality_report?.risk,
|
||
})).filter((p) => p.src),
|
||
)
|
||
: []
|
||
}
|
||
|
||
function collectSubjectAssets(job: Job | null): SubjectAssetPreview[] {
|
||
return job
|
||
? job.frames.flatMap((f) =>
|
||
(f.elements ?? []).flatMap((element) =>
|
||
(element.subject_assets ?? []).map((asset) => ({
|
||
frameIdx: f.index,
|
||
elementId: element.id,
|
||
assetId: asset.id,
|
||
label: asset.label || `${element.name_zh} · ${asset.view}`,
|
||
src: apiAssetUrl(asset.url),
|
||
width: asset.width,
|
||
height: asset.height,
|
||
view: asset.view,
|
||
})),
|
||
).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 sceneAssets = collectSceneAssets(job)
|
||
const subjectAssets = collectSubjectAssets(job)
|
||
const cleanedCount = frames.filter((x) => x.cleaned_url).length
|
||
const sceneAssetCount = sceneAssets.length
|
||
const subjectAssetCount = subjectAssets.length
|
||
const selectedFrameCount = frames.filter((f) => d.selectedFrames.has(f.index)).length
|
||
const targetFrameCount = selectedFrameCount || frames.length
|
||
const qualityRiskCount = frames.filter((f) => f.quality_report?.risk && f.quality_report.risk !== "ok").length
|
||
const preparedUnits = Math.min(targetFrameCount, sceneAssetCount) + (subjectAssetCount > 0 ? 1 : 0)
|
||
const totalUnits = Math.max(1, targetFrameCount + 1)
|
||
const prepPct = Math.min(100, Math.round((preparedUnits / totalUnits) * 100))
|
||
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 || subjectAssets.length > 0 || completedVideos.length > 0
|
||
? "done"
|
||
: keyframeStatus(job)
|
||
|
||
type VisualPreview =
|
||
| { id: string; kind: "frame"; group: string; frameIdx: number; src: string; label: string; caption: string; borderClass: string; aspect: string }
|
||
| { id: string; kind: "scene"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
|
||
| { id: string; kind: "subject"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
|
||
| { id: string; kind: "video"; group: string; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string; aspect: string }
|
||
|
||
const [hoverPreview, setHoverPreview] = useState<PreviewAnchor<string> | null>(null)
|
||
const [pinnedPreview, setPinnedPreview] = useState<PreviewAnchor<string> | null>(null)
|
||
const [deleteVideoTarget, setDeleteVideoTarget] = useState<{ id: string; label: string; caption: string } | null>(null)
|
||
const rootRef = useRef<HTMLDivElement>(null)
|
||
|
||
const previews: VisualPreview[] = [
|
||
...(job && jobId ? frames.map((f) => ({
|
||
id: `frame:${f.index}`,
|
||
kind: "frame" as const,
|
||
group: "关键帧",
|
||
frameIdx: f.index,
|
||
src: effectiveFrameUrl(jobId, f),
|
||
label: `分镜 ${f.index + 1}`,
|
||
caption: `${f.timestamp.toFixed(2)}s${f.quality_report?.risk && f.quality_report.risk !== "ok" ? " · 风险" : ""}`,
|
||
borderClass: f.quality_report?.risk === "bad" ? "border-rose-300/70" : f.quality_report?.risk === "warn" ? "border-amber-300/70" : "border-orange-300/50",
|
||
aspect,
|
||
})) : []),
|
||
...subjectAssets.map((p) => ({
|
||
id: `subject:${p.frameIdx}:${p.assetId}`,
|
||
kind: "subject" as const,
|
||
group: "主体包",
|
||
frameIdx: p.frameIdx,
|
||
assetId: p.assetId,
|
||
src: p.src,
|
||
label: p.label,
|
||
caption: `${p.width}×${p.height}`,
|
||
borderClass: "border-violet-300/65",
|
||
aspect: p.width && p.height ? `${p.width}/${p.height}` : "1/1",
|
||
})),
|
||
...sceneAssets.map((p) => ({
|
||
id: `scene:${p.frameIdx}:${p.assetId}`,
|
||
kind: "scene" as const,
|
||
group: "场景图",
|
||
frameIdx: p.frameIdx,
|
||
assetId: p.assetId,
|
||
src: p.src,
|
||
label: p.label,
|
||
caption: `${p.width}×${p.height}`,
|
||
borderClass: p.risk === "bad" ? "border-rose-300/70" : p.risk === "warn" ? "border-amber-300/70" : "border-emerald-300/60",
|
||
aspect: p.width && p.height ? `${p.width}/${p.height}` : aspect,
|
||
})),
|
||
...videos.map((v, i) => {
|
||
const videoSrc = apiAssetUrl(v.url)
|
||
const posterSrc = apiAssetUrl(v.poster_url)
|
||
return {
|
||
id: `video:${v.id}`,
|
||
kind: "video" as const,
|
||
group: "视频任务",
|
||
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",
|
||
aspect,
|
||
}
|
||
}),
|
||
]
|
||
|
||
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 === "subject" ? "bg-white" : "bg-black"}`}
|
||
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: p.aspect }}
|
||
onMouseEnter={(e) => setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||
onMouseLeave={() => setHoverPreview(null)}
|
||
>
|
||
<div className="absolute -top-6 left-0 z-[68] rounded bg-black/75 px-1.5 py-0.5 text-[9px] font-medium text-white/80 backdrop-blur">
|
||
{p.group}
|
||
</div>
|
||
<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 === "scene" || p.kind === "subject") {
|
||
d.onCopyImage?.({
|
||
kind: "asset",
|
||
frame_idx: p.frameIdx,
|
||
element_id: p.assetId,
|
||
cutout_id: p.assetId,
|
||
label: p.label,
|
||
})
|
||
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(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 === "video" ? " / 复制 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 === "frame" ? "object-cover" : "object-contain"}`} />
|
||
)}
|
||
{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 === "scene" ? "场景" : p.kind === "subject" ? "主体" : "视频"}
|
||
</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 === "scene" || p.kind === "subject") && d.onCopyImage && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
d.onCopyImage?.({
|
||
kind: "asset",
|
||
frame_idx: p.frameIdx,
|
||
element_id: p.assetId,
|
||
cutout_id: p.assetId,
|
||
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()
|
||
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 === "video" && d.onDeleteVideo && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setDeleteVideoTarget({ id: p.videoId, label: p.label, caption: p.caption })
|
||
}}
|
||
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>
|
||
)}
|
||
|
||
{deleteVideoTarget && (
|
||
<DeleteConfirmDialog
|
||
title="删除这个视频任务?"
|
||
description={`会删除${deleteVideoTarget.label}的生成任务和本地视频文件。${deleteVideoTarget.caption}`}
|
||
confirmLabel="删除视频任务"
|
||
onCancel={() => setDeleteVideoTarget(null)}
|
||
onConfirm={() => {
|
||
const id = deleteVideoTarget.id
|
||
setDeleteVideoTarget(null)
|
||
d.onDeleteVideo?.(id)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{(() => {
|
||
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={p.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="rounded-md border border-white/10 bg-black/20 p-2">
|
||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||
<div className="text-[11px] font-semibold text-[var(--text-strong)]">素材准备进度</div>
|
||
<div className="font-mono text-[10px] text-[var(--text-faint)]">{prepPct}%</div>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||
<div
|
||
className="h-full rounded-full bg-emerald-400 transition-all"
|
||
style={{ width: `${prepPct}%` }}
|
||
/>
|
||
</div>
|
||
<div className="mt-1.5 flex items-center justify-between gap-2 text-[9.5px] text-[var(--text-faint)]">
|
||
<span>{targetFrameCount} 张素材帧</span>
|
||
{qualityRiskCount > 0 ? (
|
||
<span className="inline-flex items-center gap-1 text-amber-300/85">
|
||
<AlertTriangle className="h-2.5 w-2.5" />
|
||
{qualityRiskCount} 个质量风险
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1 text-emerald-300/80">
|
||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||
无质量风险
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2 grid grid-cols-2 gap-2 text-[10.5px] text-[var(--text-soft)]">
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
|
||
disabled={frames.length === 0}
|
||
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-orange-300/50 hover:bg-orange-400/10 disabled:opacity-35"
|
||
title="打开素材准备 / 审核面板"
|
||
>
|
||
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
|
||
<ImageIcon className="h-3 w-3 text-orange-300" />
|
||
{frames.length}
|
||
</div>
|
||
<div>关键帧素材</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
|
||
disabled={!job || frames.length === 0}
|
||
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-violet-300/50 hover:bg-violet-400/10 disabled:opacity-35"
|
||
title="用多张关键帧参考重绘统一主体的六张标准站立图"
|
||
>
|
||
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
|
||
<Package className="h-3 w-3 text-violet-300" />
|
||
{subjectAssetCount}
|
||
</div>
|
||
<div>统一主体</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
|
||
disabled={!job || frames.length === 0}
|
||
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-emerald-300/50 hover:bg-emerald-400/10 disabled:opacity-35"
|
||
title="基于主体资产生成去主体 / 相似 / 换风格场景图"
|
||
>
|
||
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
|
||
<Sparkles className="h-3 w-3 text-emerald-300" />
|
||
{sceneAssetCount}/{targetFrameCount}
|
||
</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="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-pink-300/50 hover:bg-pink-400/10 disabled:opacity-35"
|
||
title="进入分镜编排和视频生成"
|
||
>
|
||
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
|
||
<Film className="h-3 w-3 text-pink-300" />
|
||
{videos.length}
|
||
</div>
|
||
<div>分镜 / 视频</div>
|
||
</button>
|
||
</div>
|
||
<div className="mt-2 text-[10.5px] leading-snug text-[var(--text-faint)]">
|
||
{frames.length > 0 ? (
|
||
<>
|
||
{cleanedCount} 已清洗 · 统一主体 {subjectAssetCount} 张 · {sceneAssetCount} 场景图 · {targetFrameCount} 素材帧 · {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()
|
||
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 ? `${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)
|
||
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={elementsCount > 0 ? "text-violet-300/90 font-medium" : ""}>{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 scale = d.framePanelScale ?? 1
|
||
const dock = d.framePanelDock ?? (d.framePanelPinned ? "left" : "canvas")
|
||
const docked = dock !== "canvas"
|
||
|
||
if (!d.job || d.expandedFrame === null) return null
|
||
const active = d.job.frames.find((f) => f.index === d.expandedFrame)
|
||
const arrayPos = active ? d.job.frames.findIndex((f) => f.index === active.index) : -1
|
||
const prevFrame = arrayPos > 0 ? d.job.frames[arrayPos - 1] : null
|
||
const nextFrame = arrayPos >= 0 && arrayPos < d.job.frames.length - 1 ? d.job.frames[arrayPos + 1] : null
|
||
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 dockText: Record<CanvasPanelDock, string> = {
|
||
canvas: "画布模式",
|
||
left: "吸附左侧",
|
||
right: "吸附右侧",
|
||
bottom: "吸附底部",
|
||
}
|
||
const dockButtonClass = (value: CanvasPanelDock) =>
|
||
`nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${
|
||
dock === value
|
||
? "bg-white text-violet-700 shadow"
|
||
: "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white"
|
||
}`
|
||
|
||
const startResize = (e: React.PointerEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const startX = e.clientX
|
||
const startY = e.clientY
|
||
const startScale = scale
|
||
const zoom = docked ? 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 overflow-hidden rounded-2xl border border-white/15 bg-black/78 text-white shadow-2xl backdrop-blur-xl"
|
||
style={{
|
||
width: panelWidth,
|
||
height: panelHeight,
|
||
maxWidth: "calc(100vw - 32px)",
|
||
maxHeight: `calc(100vh - ${FLOATING_PANEL_EDGE_INSET * 2}px)`,
|
||
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 px-3 text-white ${docked ? "cursor-default" : "cursor-move"}`}
|
||
style={{ background: "var(--grad-ai)" }}
|
||
>
|
||
<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 className="ml-1 flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
disabled={!prevFrame}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (prevFrame) d.onExpandFrame(prevFrame.index)
|
||
}}
|
||
className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 inline-flex items-center justify-center"
|
||
title="上一张关键帧"
|
||
>
|
||
<ChevronLeft className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!nextFrame}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (nextFrame) d.onExpandFrame(nextFrame.index)
|
||
}}
|
||
className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 inline-flex items-center justify-center"
|
||
title="下一张关键帧"
|
||
>
|
||
<ChevronRight className="h-3.5 w-3.5" />
|
||
</button>
|
||
<span className="shrink-0 text-[10px] font-mono text-white/50">
|
||
{arrayPos >= 0 ? `${String(arrayPos + 1).padStart(2, "0")} / ${String(d.job.frames.length).padStart(2, "0")}` : ""}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="mr-1 text-[10px] text-white/60">
|
||
{dockText[dock]}
|
||
</span>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("canvas") }} className={dockButtonClass("canvas")} title="回到画布模式">
|
||
<Move className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("left") }} className={dockButtonClass("left")} title="吸附到左侧">
|
||
<PanelLeft className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("right") }} className={dockButtonClass("right")} title="吸附到右侧">
|
||
<PanelRight className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("bottom") }} className={dockButtonClass("bottom")} title="吸附到底部">
|
||
<PanelBottom className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }}
|
||
className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none"
|
||
title="缩小面板"
|
||
>
|
||
-
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); setScale(1) }}
|
||
className="nodrag h-6 min-w-10 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-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none"
|
||
title="放大面板"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
|
||
className="nodrag h-6 w-6 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.5 w-3.5" />
|
||
</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}
|
||
clipboard={d.clipboard}
|
||
onCopyImage={d.onCopyImage}
|
||
onGenerateProductFusionVideo={d.onGenerateProductFusionVideo}
|
||
/>
|
||
</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-pink-400/35 hover:text-white inline-flex items-center justify-center"
|
||
title="拖动右下角缩放面板"
|
||
>
|
||
<Maximize2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)
|
||
|
||
if (docked && typeof document !== "undefined") {
|
||
const fixedStyle =
|
||
dock === "left"
|
||
? { left: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
|
||
: dock === "right"
|
||
? { right: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
|
||
: { left: "50%", bottom: FLOATING_PANEL_EDGE_INSET, transform: "translateX(-50%)" }
|
||
return createPortal(
|
||
<div
|
||
className="fixed z-[240]"
|
||
style={fixedStyle}
|
||
>
|
||
{panel}
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
return panel
|
||
}
|
||
|
||
/* ============================================================
|
||
5. ASRNode — 音频转写
|
||
============================================================ */
|
||
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)]">
|
||
OpenAI-compatible ASR · 英文带时间戳分段
|
||
</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
|
||
============================================================ */
|
||
export function RewriteNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const rewrittenText = d.job?.audio_script?.rewritten_text?.trim() ?? ""
|
||
return (
|
||
<NodeShell
|
||
type="ai" status={rewrittenText ? "done" : d.job?.audio_script?.status === "rewriting" ? "running" : "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")}
|
||
>
|
||
{rewrittenText ? (
|
||
<div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-2 text-[11.5px] leading-relaxed text-[var(--text-strong)]">
|
||
{rewrittenText}
|
||
</div>
|
||
) : (
|
||
<div className="text-[11px] text-[var(--text-soft)]">转录完成后自动接 SKG 卖点改写。</div>
|
||
)}
|
||
<div className="mt-1.5 text-[10px] text-[var(--text-faint)]">{d.job?.audio_script?.rewrite_model || "AUDIO_REWRITE_MODEL"}</div>
|
||
</NodeShell>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
5b. AudioNode — 合并 ASR + 翻译 + 改写 + MiniMax 配音
|
||
============================================================ */
|
||
export function AudioNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const job = d.job
|
||
const transcript = job?.transcript ?? []
|
||
const audioScript = job?.audio_script
|
||
const rewrittenText = audioScript?.rewritten_text?.trim() ?? ""
|
||
const voiceUrl = apiAssetUrl(audioScript?.voice_url)
|
||
const hasASR = transcript.length > 0
|
||
const isRewriting = audioScript?.status === "rewriting"
|
||
const hasVideo = !!job?.video_url
|
||
const isAudioBusy = !!job && (job.status === "transcribing" || isRewriting)
|
||
const audioButtonDisabled = !job || !hasVideo || isAudioBusy
|
||
const audioButtonLabel = !hasVideo
|
||
? "等待视频就绪"
|
||
: isAudioBusy
|
||
? "正在提取音频"
|
||
: hasASR || rewrittenText
|
||
? "重新提取音频"
|
||
: "提取音频"
|
||
const originalPreview = transcript
|
||
.slice(0, 2)
|
||
.map((s) => (s.zh || s.en).trim())
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
const status: NodeStatus = !job
|
||
? "pending"
|
||
: job.status === "transcribing" || isRewriting
|
||
? "running"
|
||
: rewrittenText || 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="space-y-2 text-[11px] text-[var(--text-soft)] leading-snug">
|
||
<div>
|
||
音轨 → ASR 转录 → 英中翻译 → SKG 口播改写 → MiniMax 配音<br />
|
||
<span className="text-[var(--text-faint)] font-mono">
|
||
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "MiniMax T2A"}
|
||
</span>
|
||
</div>
|
||
{job && (
|
||
<button
|
||
type="button"
|
||
disabled={audioButtonDisabled}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (audioButtonDisabled) return
|
||
void d.onTranscribeAudio?.(job.id)
|
||
}}
|
||
className="inline-flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-violet-300/25 bg-violet-400/10 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-strong)] transition hover:border-violet-200/45 hover:bg-violet-400/18 disabled:cursor-not-allowed disabled:border-white/10 disabled:bg-white/[0.03] disabled:text-[var(--text-faint)]"
|
||
>
|
||
{isAudioBusy ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : hasASR || rewrittenText ? (
|
||
<RotateCcw className="h-3.5 w-3.5" />
|
||
) : (
|
||
<PlayCircle className="h-3.5 w-3.5" />
|
||
)}
|
||
{audioButtonLabel}
|
||
</button>
|
||
)}
|
||
{(originalPreview || rewrittenText) && (
|
||
<div className="grid gap-2">
|
||
{originalPreview && (
|
||
<div className="rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-2">
|
||
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-[var(--text-faint)]">改前 · 原音频</div>
|
||
<div className="line-clamp-3 text-[11px] leading-relaxed text-[var(--text-soft)] break-words">{originalPreview}</div>
|
||
</div>
|
||
)}
|
||
{rewrittenText && (
|
||
<div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-2">
|
||
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-emerald-200/80">改后 · SKG 口播</div>
|
||
<div className="line-clamp-4 text-[11.5px] leading-relaxed text-[var(--text-strong)] break-words">{rewrittenText}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{voiceUrl && (
|
||
<audio controls src={voiceUrl} className="h-7 w-full" />
|
||
)}
|
||
{isRewriting && (
|
||
<div className="text-[10.5px] text-[var(--text-faint)]">正在生成改写文案和配音…</div>
|
||
)}
|
||
{audioScript?.error && rewrittenText && !voiceUrl && (
|
||
<div className="text-[10.5px] text-amber-300/85">配音待生成:{audioScript.error}</div>
|
||
)}
|
||
</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()
|
||
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 [deleteVideoTarget, setDeleteVideoTarget] = useState<{ id: string; label: string; caption: 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()
|
||
setDeleteVideoTarget({
|
||
id: v.id,
|
||
label: `视频 ${i + 1}`,
|
||
caption: `分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · ${v.status}`,
|
||
})
|
||
}}
|
||
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>
|
||
)}
|
||
{deleteVideoTarget && (
|
||
<DeleteConfirmDialog
|
||
title="删除这个视频任务?"
|
||
description={`会删除${deleteVideoTarget.label}的生成任务和本地视频文件。${deleteVideoTarget.caption}`}
|
||
confirmLabel="删除视频任务"
|
||
onCancel={() => setDeleteVideoTarget(null)}
|
||
onConfirm={() => {
|
||
const id = deleteVideoTarget.id
|
||
setDeleteVideoTarget(null)
|
||
d.onDeleteVideo?.(id)
|
||
}}
|
||
/>
|
||
)}
|
||
{(() => {
|
||
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>
|
||
)
|
||
}
|