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

@@ -17,8 +17,8 @@ import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import {
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
effectiveFrameUrl, resolveImageRefUrl,
type Job, type ImageRef, type StoryboardScene, type GeneratedVideoDraft,
generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
@@ -77,7 +77,6 @@ export default function Home() {
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
const [workbenchOpen, setWorkbenchOpen] = useState(false)
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
const [videoDrafts, setVideoDrafts] = useState<GeneratedVideoDraft[]>([])
const flowRef = useRef<any>(null)
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
@@ -106,7 +105,6 @@ export default function Home() {
const handleSwitchJob = useCallback((id: string) => {
setActiveJobId(id)
setSelectedFrames(new Set())
setVideoDrafts([])
}, [])
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -217,14 +215,12 @@ export default function Home() {
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
}, [])
const handleQuickGenerateVideo = useCallback((frameIdx: number, scene: StoryboardScene) => {
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
const posterRef = scene.product_image ?? scene.subject_image ?? scene.scene_image ?? scene.action_image ?? null
const posterUrl = posterRef ? resolveImageRefUrl(job.id, posterRef) : effectiveFrameUrl(job.id, frame)
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
const prompt = [
`Vertical 9:16 short product video for SKG, ${duration.toFixed(1)} seconds.`,
@@ -240,21 +236,25 @@ export default function Home() {
"High quality realistic commercial video, clean background, no captions, no platform UI, no TikTok watermark, no extra text.",
].join("\n")
const draft: GeneratedVideoDraft = {
id: `quick-${frameIdx}-${Date.now().toString(36)}`,
frame_idx: frameIdx,
label: `分镜 ${frameIdx + 1} · 快速视频`,
prompt,
provider: "Quick Prompt",
poster_url: posterUrl,
duration,
created_at: Date.now(),
status: "ready",
try {
toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
const updated = await generateStoryboardVideo(job.id, frameIdx, {
prompt,
duration,
subject_image: scene.subject_image ?? null,
scene_image: scene.scene_image ?? null,
product_image: scene.product_image ?? null,
action_image: scene.action_image ?? null,
model,
size: "720x1280",
})
setJob(updated)
void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("视频任务已进入 Video Gen 节点")
} catch (e) {
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
}
setVideoDrafts((prev) => [draft, ...prev.filter((x) => x.id !== draft.id)].slice(0, 8))
void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("已生成视频 prompt · 已显示到 Video Gen 节点")
}, [job])
}, [job, setJob])
// URL ?job=xxx,yyy 自动恢复多个 job
useEffect(() => {
@@ -308,8 +308,9 @@ export default function Home() {
}
prevStatusRef.current = job.status
const runningVideo = !!job.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
if (TERMINAL.includes(job.status)) {
if (TERMINAL.includes(job.status) && !runningVideo) {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
return
}
@@ -320,7 +321,7 @@ export default function Home() {
} catch { /* silent */ }
}, 1500)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [job?.id, job?.status])
}, [job?.id, job?.status, job?.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join("|")])
const nodeData: NodeData = useMemo(() => ({
job,
@@ -332,7 +333,6 @@ export default function Home() {
expandedFrame,
framePanelScale,
framePanelPinned,
videoDrafts,
onSubmitUrl: handleSubmit,
onUploadFile: handleUpload,
onAnalyze: handleAnalyze,
@@ -354,7 +354,7 @@ export default function Home() {
setWorkbenchOpen(true)
},
onCopyImage: handleCopyImage,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, videoDrafts, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(

View File

@@ -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>

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>

View File

@@ -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>

View File

@@ -69,16 +69,19 @@ export interface StoryboardScene {
reference_ids?: string[]
}
export interface GeneratedVideoDraft {
export interface GeneratedVideo {
id: string
provider_id?: string
frame_idx: number
label: string
prompt: string
provider: "Quick Prompt" | "Seedance" | "Kling" | "Veo 3"
poster_url: string
model: string
status: "queued" | "in_progress" | "completed" | "failed"
url?: string
poster_url?: string
duration: number
progress: number
error?: string
created_at: number
status: "ready" | "queued" | "failed"
}
// 把 ImageRef 解析成可显示的 src URL
@@ -139,9 +142,16 @@ export interface Job {
frames: KeyFrame[]
transcript: TranscriptSegment[]
storyboard_images?: StoryboardImage[]
generated_videos?: GeneratedVideo[]
error?: string
}
export function apiAssetUrl(path?: string | null): string {
if (!path) return ""
if (/^https?:\/\//i.test(path)) return path
return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`
}
export async function createJob(tkUrl: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs`, {
method: "POST",
@@ -341,6 +351,32 @@ export async function updateStoryboard(
return res.json()
}
export async function generateStoryboardVideo(
jobId: string,
frameIdx: number,
body: {
prompt: string
duration?: number
subject_image?: ImageRef | null
scene_image?: ImageRef | null
product_image?: ImageRef | null
action_image?: ImageRef | null
model?: string
size?: string
},
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/video`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`generateStoryboardVideo ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function deleteCutout(jobId: string, frameIdx: number, elementId: string, cutoutId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
method: "DELETE",