auto-save 2026-05-13 20:29 (~9)
This commit is contained in:
@@ -623,8 +623,8 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
{/* ---- VideoGen — Kanban ---- */}
|
||||
{key === "videogen" && (
|
||||
<>
|
||||
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Sora 2">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">/v1/videos 待开通(IT)</div>
|
||||
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Seedance / Kling / Veo 3">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">通过 /v1/videos 网关提交,模型 ID 走环境变量映射</div>
|
||||
</KanbanCard>
|
||||
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">字节跳动 · 需独立 API key</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,13 +15,19 @@ interface Props {
|
||||
onJobUpdate?: (j: Job) => void
|
||||
clipboard: ImageRef | null // 全局剪贴板(page.tsx 提供)
|
||||
focusedFrame: number | null
|
||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene) => void
|
||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
const emptyScene = (): StoryboardScene => ({
|
||||
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
|
||||
})
|
||||
|
||||
const VIDEO_MODELS = [
|
||||
{ value: "seedance", label: "Seedance" },
|
||||
{ value: "kling", label: "Kling" },
|
||||
{ value: "veo3", label: "Veo 3" },
|
||||
] as const
|
||||
|
||||
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
@@ -31,6 +37,8 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedTick, setSavedTick] = useState(0)
|
||||
const [panelHeight, setPanelHeight] = useState(320)
|
||||
const [videoModel, setVideoModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance")
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Esc 关闭
|
||||
@@ -115,6 +123,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
}
|
||||
|
||||
const hasVideoRefs = !!(form.subject_image || form.scene_image || form.product_image || form.action_image)
|
||||
const currentModelLabel = VIDEO_MODELS.find((m) => m.value === videoModel)?.label ?? "Seedance"
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -271,23 +280,48 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 快速生成:先产出视频 prompt / 任务卡,结果显示到 Video Gen 节点 */}
|
||||
{/* 快速生成:直接调用生视频 API,结果显示到 Video Gen 节点 */}
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-[12.5px] font-semibold text-white">生成视频</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-black/35 p-0.5">
|
||||
{VIDEO_MODELS.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => setVideoModel(m.value)}
|
||||
className={`h-6 rounded px-2 text-[10.5px] transition ${
|
||||
videoModel === m.value
|
||||
? "bg-violet-500 text-white shadow"
|
||||
: "text-white/50 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
title={`使用 ${m.label} 生成视频`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
disabled={!hasVideoRefs || focusedIdx === null}
|
||||
onClick={() => {
|
||||
disabled={!hasVideoRefs || focusedIdx === null || generating}
|
||||
onClick={async () => {
|
||||
if (focusedIdx === null) return
|
||||
queueSave(form)
|
||||
onGenerateVideo?.(focusedIdx, form)
|
||||
setGenerating(true)
|
||||
try {
|
||||
await onGenerateVideo?.(focusedIdx, form, videoModel)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}}
|
||||
className="w-full py-3 rounded-lg text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 bg-gradient-to-r from-rose-500 to-violet-500 text-white border border-violet-300/40 shadow-lg shadow-violet-500/20 hover:from-rose-400 hover:to-violet-400 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={hasVideoRefs ? "根据当前 4 图槽和改造目标生成视频 prompt,并推送到 Video Gen 节点" : "先粘贴至少一张参考图"}
|
||||
title={hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API,结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
快速生成视频
|
||||
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
|
||||
调用 {currentModelLabel} 生成视频
|
||||
</button>
|
||||
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
|
||||
当前先生成可交付的视频 prompt / 任务卡并显示到 Video Gen 节点;后续接 Seedance / Kling / Veo 3 时直接替换为真实视频输出。
|
||||
用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user