auto-save 2026-05-13 20:29 (~9)

This commit is contained in:
2026-05-13 20:29:23 +08:00
parent 989cc912ec
commit e79c33dabd
9 changed files with 315 additions and 120 deletions

View File

@@ -8,8 +8,8 @@ import {
} from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import {
type Job, type ImageRef, type GeneratedVideoDraft,
effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
type Job, type ImageRef,
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"
@@ -23,7 +23,6 @@ export interface NodeData {
expandedFrame: number | null
framePanelScale?: number
framePanelPinned?: boolean
videoDrafts?: GeneratedVideoDraft[]
onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void
onAnalyze: () => void
@@ -943,22 +942,39 @@ export function StoryboardNode({ data, selected }: any) {
============================================================ */
export function VideoGenNode({ data, selected }: any) {
const d: NodeData = data
const drafts = d.videoDrafts ?? []
const status: NodeStatus = drafts.length > 0 ? "done" : "pending"
const videos = d.job?.generated_videos ?? []
const running = videos.some((v) => v.status === "queued" || v.status === "in_progress")
const completed = videos.filter((v) => v.status === "completed" && v.url)
const failed = videos.some((v) => v.status === "failed")
const status: NodeStatus = running ? "running" : completed.length > 0 ? "done" : failed ? "failed" : "pending"
const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
? `${d.job.width}/${d.job.height}`
: "9/16"
const modelLabel = (model: string) => {
const m = model.toLowerCase()
if (m.includes("kling")) return "Kling"
if (m.includes("veo")) return "Veo 3"
if (m.includes("seedance")) return "Seedance"
return model || "Video"
}
return (
<div className="relative" style={{ width: 280 }}>
{drafts.length > 0 && (
{videos.length > 0 && (
<div
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
style={{ bottom: "calc(100% + 12px)" }}
>
{drafts.slice(0, 6).map((v, i) => (
{videos.slice(0, 6).map((v, i) => {
const videoSrc = apiAssetUrl(v.url)
const posterSrc = apiAssetUrl(v.poster_url)
const ready = v.status === "completed" && !!videoSrc
const progress = Math.max(0, Math.min(100, v.progress || 0))
return (
<div
key={v.id}
className="group relative rounded-md border border-rose-300/55 transition shadow-lg hover:-translate-y-0.5 bg-black"
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 bg-black ${
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
}`}
style={{ aspectRatio: aspect }}
>
<button
@@ -967,17 +983,44 @@ export function VideoGenNode({ data, selected }: any) {
e.stopPropagation()
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
}}
title={`${v.label} · 点击复制视频 prompt`}
title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 prompt`}
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
>
<img
src={v.poster_url}
alt={v.label}
className="absolute inset-0 w-full h-full object-cover"
/>
{ready ? (
<video
src={videoSrc}
poster={posterSrc}
muted
loop
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-cover"
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
onMouseLeave={(e) => {
const el = e.target as HTMLVideoElement
el.pause()
el.currentTime = 0
}}
/>
) : posterSrc ? (
<img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
) : (
<div className="absolute inset-0 bg-violet-950/50" />
)}
{!ready && (
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
{v.status === "failed" ? (
<X className="h-4 w-4 text-rose-200" />
) : (
<Loader2 className="h-4 w-4 animate-spin text-white/85" />
)}
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-1.5 py-1 text-left">
<div className="truncate text-[9.5px] font-semibold text-white"> {i + 1}</div>
<div className="truncate text-[8.5px] font-mono text-white/60">{v.duration.toFixed(1)}s</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>
<div
@@ -991,28 +1034,34 @@ export function VideoGenNode({ data, selected }: any) {
>
<div className="rounded-lg overflow-hidden border-2 border-rose-300/60 bg-black shadow-2xl" style={{ width: 300 }}>
<div style={{ aspectRatio: aspect }}>
<img src={v.poster_url} alt="" className="w-full h-full object-cover" />
{ready ? (
<video src={videoSrc} poster={posterSrc} muted loop autoPlay playsInline controls className="h-full w-full object-cover" />
) : posterSrc ? (
<img src={posterSrc} alt="" className="w-full h-full object-cover" />
) : (
<div className="h-full w-full bg-violet-950/60" />
)}
</div>
<div className="space-y-1 bg-black/90 px-2 py-1.5 text-white">
<div className="flex items-center justify-between gap-2 text-[10.5px]">
<span className="truncate">{v.label}</span>
<span className="shrink-0 font-mono text-white/55">{v.provider}</span>
<span className="truncate"> {v.frame_idx + 1}</span>
<span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
</div>
<div className="line-clamp-3 text-[9.5px] leading-snug text-white/55">
{v.prompt}
{v.status === "failed" ? (v.error || "生成失败") : v.prompt}
</div>
</div>
</div>
</div>
</div>
))}
)})}
</div>
)}
<NodeShell
type="ai" status={status}
icon={<Film className="h-4 w-4" />}
title="生成视频 · Video Gen"
subtitle={`STEP 7 · 首帧 + 动作 prompt${drafts.length > 0 ? ` · ${drafts.length} 个任务` : ""}`}
subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length}视频任务` : ""}`}
selected={selected}
>
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
@@ -1022,9 +1071,9 @@ export function VideoGenNode({ data, selected }: any) {
</div>
))}
</div>
{drafts.length > 0 && (
{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)]">
{drafts.length} prompt ·
{videos.length} · {completed.length} {running ? " · 生成中" : ""}
</div>
)}
</NodeShell>