diff --git a/.memory/worklog.json b/.memory/worklog.json
index aeba63c..184d766 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -2308,6 +2308,13 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 20:18 (~4)",
"files_changed": 2
+ },
+ {
+ "ts": "2026-05-13T20:23:53+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-13 20:23 (~2)",
+ "hash": "989cc91",
+ "files_changed": 2
}
]
}
diff --git a/api/.env.example b/api/.env.example
index a8d4e28..7199598 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -7,7 +7,11 @@ ASR_MODEL=whisper-1
TRANSLATE_MODEL=gemini-2.5-flash
REWRITE_MODEL=gemini-2.5-pro
IMAGE_MODEL=gemini-3-pro-image-preview
-VIDEO_MODEL=sora-2
+VIDEO_MODEL=seedance
+VIDEO_MODEL_SEEDANCE=seedance
+VIDEO_MODEL_KLING=kling
+VIDEO_MODEL_VEO3=veo3
+VIDEO_DURATION_FIELD=seconds
# 工作目录
JOBS_DIR=./jobs
diff --git a/api/main.py b/api/main.py
index 1f61d4b..cc991f7 100644
--- a/api/main.py
+++ b/api/main.py
@@ -1584,6 +1584,66 @@ def video_seconds(duration: float) -> str:
return "12"
+def resolve_video_model(raw: str | None) -> str:
+ requested = (raw or VIDEO_MODEL or "seedance").strip()
+ lowered = requested.lower()
+ if lowered in {"sora", "sora-2", "sora_2"}:
+ raise HTTPException(400, "Sora 已停用,请选择 Seedance / Kling / Veo 3")
+ return VIDEO_MODEL_ALIASES.get(lowered, requested)
+
+
+def normalize_video_status(status: str | None) -> Literal["queued", "in_progress", "completed", "failed"]:
+ s = (status or "queued").lower()
+ if s in {"completed", "complete", "succeeded", "success", "done"}:
+ return "completed"
+ if s in {"failed", "failure", "error", "cancelled", "canceled", "expired"}:
+ return "failed"
+ if s in {"running", "processing", "in_progress", "generating", "started"}:
+ return "in_progress"
+ return "queued"
+
+
+def video_progress(data: dict, fallback: int) -> int:
+ raw = data.get("progress", data.get("percentage", data.get("percent", fallback)))
+ try:
+ value = int(float(raw))
+ except Exception:
+ value = fallback
+ return max(0, min(100, value))
+
+
+def video_url_from_response(data: dict) -> str:
+ for key in ("url", "video_url", "output_url", "download_url"):
+ v = data.get(key)
+ if isinstance(v, str) and v:
+ return v
+ arr = data.get("data")
+ if isinstance(arr, list) and arr:
+ first = arr[0]
+ if isinstance(first, dict):
+ for key in ("url", "video_url", "output_url", "download_url"):
+ v = first.get(key)
+ if isinstance(v, str) and v:
+ return v
+ output = data.get("output")
+ if isinstance(output, dict):
+ for key in ("url", "video_url", "download_url"):
+ v = output.get(key)
+ if isinstance(v, str) and v:
+ return v
+ return ""
+
+
+def download_generated_video(client, base: str, headers: dict, provider_id: str, direct_url: str, out_mp4: Path) -> None:
+ if direct_url:
+ url = direct_url if direct_url.startswith("http") else f"{base}{direct_url if direct_url.startswith('/') else '/' + direct_url}"
+ r = client.get(url, headers=headers if url.startswith(base) else None)
+ else:
+ r = client.get(f"{base}/videos/{provider_id}/content", headers=headers)
+ r.raise_for_status()
+ out_mp4.write_bytes(r.content)
+
+
def render_storyboard_video(job_id: str, local_id: str, provider_id: str, ref_path: Path, prompt: str, model: str, seconds: str, size: str) -> None:
import httpx
@@ -1594,55 +1654,52 @@ def render_storyboard_video(job_id: str, local_id: str, provider_id: str, ref_pa
headers = {"Authorization": f"Bearer {LLM_API_KEY}"}
try:
- prepare_video_reference(ref_path, ref_img)
- update_generated_video(job_id, local_id, status="in_progress", progress=5)
- with httpx.Client(timeout=120) as client:
- with ref_img.open("rb") as fh:
- create = client.post(
- f"{base}/videos",
- headers=headers,
- data={
- "model": model,
- "prompt": prompt,
- "seconds": seconds,
- "size": size,
- },
- files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
- )
- create.raise_for_status()
- data = create.json()
- video_api_id = data.get("id") or provider_id
- update_generated_video(job_id, local_id, provider_id=video_api_id, status=data.get("status", "queued"), progress=int(data.get("progress") or 5))
+ prepare_video_reference(ref_path, ref_img)
+ update_generated_video(job_id, local_id, status="in_progress", progress=5)
+ with httpx.Client(timeout=120) as client:
+ payload = {"model": model, "prompt": prompt, "size": size}
+ payload[VIDEO_DURATION_FIELD] = seconds
+ with ref_img.open("rb") as fh:
+ create = client.post(
+ f"{base}/videos",
+ headers=headers,
+ data=payload,
+ files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
+ )
+ create.raise_for_status()
+ data = create.json()
+ video_api_id = data.get("id") or provider_id or local_id
+ status = normalize_video_status(data.get("status"))
+ progress = video_progress(data, 5)
+ direct_url = video_url_from_response(data)
+ update_generated_video(job_id, local_id, provider_id=video_api_id, status=status, progress=progress)
- status = data.get("status", "queued")
- progress = int(data.get("progress") or 5)
- deadline = time.time() + 420
- while status in {"queued", "in_progress"} and time.time() < deadline:
- time.sleep(8)
- poll = client.get(f"{base}/videos/{video_api_id}", headers=headers)
- poll.raise_for_status()
- pdata = poll.json()
- status = pdata.get("status", status)
- progress = int(pdata.get("progress") or progress)
- update_generated_video(job_id, local_id, status=status, progress=progress)
+ deadline = time.time() + 420
+ while status in {"queued", "in_progress"} and time.time() < deadline:
+ time.sleep(8)
+ poll = client.get(f"{base}/videos/{video_api_id}", headers=headers)
+ poll.raise_for_status()
+ pdata = poll.json()
+ status = normalize_video_status(pdata.get("status"))
+ progress = video_progress(pdata, progress)
+ direct_url = video_url_from_response(pdata) or direct_url
+ update_generated_video(job_id, local_id, status=status, progress=progress)
- if status != "completed":
- update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress)
- return
+ if status != "completed":
+ update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress)
+ return
- content = client.get(f"{base}/videos/{video_api_id}/content", headers=headers)
- content.raise_for_status()
- out_mp4.write_bytes(content.content)
- update_generated_video(
- job_id,
- local_id,
- status="completed",
- progress=100,
- url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4",
- error="",
- )
+ download_generated_video(client, base, headers, video_api_id, direct_url, out_mp4)
+ update_generated_video(
+ job_id,
+ local_id,
+ status="completed",
+ progress=100,
+ url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4",
+ error="",
+ )
except Exception as e:
- update_generated_video(job_id, local_id, status="failed", error=str(e)[:500])
+ update_generated_video(job_id, local_id, status="failed", error=str(e)[:500])
@app.post("/jobs/{job_id}/frames/{idx}/storyboard/video", response_model=Job)
@@ -1666,7 +1723,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
poster = storyboard_ref_url(job_id, ref) or f"/jobs/{job_id}/frames/{idx}.jpg"
local_id = uuid.uuid4().hex[:12]
- model = req.model.strip() or VIDEO_MODEL
+ model = resolve_video_model(req.model)
seconds = video_seconds(float(req.duration or 4))
item = GeneratedVideo(
id=local_id,
@@ -1686,6 +1743,14 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
return job
+@app.get("/jobs/{job_id}/storyboard-videos/{video_id}.mp4")
+def get_storyboard_video(job_id: str, video_id: str):
+ p = job_dir(job_id) / "storyboard_videos" / video_id / "video.mp4"
+ if not p.exists():
+ raise HTTPException(404, "storyboard video not found")
+ return FileResponse(p, media_type="video/mp4")
+
+
@app.put("/jobs/{job_id}/frames/{idx}/storyboard", response_model=Job)
def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
"""更新分镜的编排字段(subject / product / scene / action / duration / reference_ids)"""
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index df84892..e00579b 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -557,7 +557,7 @@
4
Vision 识别
识别场景和候选元素,只是候选,不应锁死。
5
元素提取
编辑/新增/删除元素,对元素反复生成提取图。
6
元素改造
把参考主体、场景、动作和 SKG 产品放入分镜结构。
- 7
生成视频
用分镜结构生成首帧和视频片段。当前未实现。
+ 7
生成视频
用分镜 4 图槽、改造目标和时长调用 Seedance / Kling / Veo 3 生视频 API,结果回写到 Video Gen 节点。
8
合成成品
片段、字幕、配音、转场合成最终 mp4。当前未实现。
@@ -760,9 +760,9 @@ api/main.py
| Video Gen / Compose |
- 未来生成视频和合成成品。 |
- 当前只是占位,不要描述成已打通。 |
- VideoGenNode、ComposeNode、未来模型接口 |
+ 承载生视频任务状态和完成后的 MP4。 |
+ 分镜工作台提交任务,Video Gen 节点只展示任务和结果。 |
+ VideoGenNode、/storyboard/video、generated_videos |
@@ -790,7 +790,7 @@ api/main.py
ASR:SKG 网关 audio endpoint 404 或渠道不可用。
Translate:本身 text 通,但产品流里依赖 ASR 段落。
Rewrite:需要 SKG 产品信息模板和目标脚本结构。
- Video Gen:sora/video endpoint 未通,Seedance/Kling/Veo3 外部 key 未接。
+ Video Gen:已接 OpenAI-compatible /videos 网关;前端可选 Seedance / Kling / Veo 3,具体模型 ID 由 VIDEO_MODEL_* 环境变量映射。
Compose:还没做本地 ffmpeg 字幕/TTS 合成。
@@ -832,14 +832,14 @@ api/main.py
- 2026-05-13 · 分镜编排增加快速生成视频任务
+ 2026-05-13 · 分镜编排接入真实生视频任务
StoryboardWorkbench
VideoGenNode
-
问题:4 图槽已经粘贴参考图后,用户需要快速交付可用于视频生成的 prompt,并在 Video Gen 节点看到结果承载。
-
改动:分镜编排明细区增加“快速生成视频”按钮,自动根据 4 图槽、时长和改造目标生成 SKG 产品视频 prompt;生成的任务卡展示在 VideoGenNode 上方,hover 可查看大图和 prompt,点击卡片复制 prompt。
-
影响:web/components/storyboard-workbench.tsx、web/components/nodes/index.tsx、web/app/page.tsx、web/lib/api.ts。当前是前端快速交付承载,后续接 Seedance / Kling / Veo 3 时替换为真实视频 URL。
+
问题:4 图槽已经粘贴参考图后,用户要直接调用生视频 API,而不是只生成 prompt 或图片任务。
+
改动:分镜编排明细区增加 Seedance / Kling / Veo 3 模型选择和“调用模型生成视频”按钮;后端新增 /jobs/{job_id}/frames/{idx}/storyboard/video,提交 /videos 网关后轮询并保存 MP4;VideoGenNode 读取 job.generated_videos 展示排队、生成中、失败和完成视频。
+
影响:api/main.py、api/.env.example、web/components/storyboard-workbench.tsx、web/components/nodes/index.tsx、web/app/page.tsx、web/lib/api.ts。Sora 不再作为默认模型,真实模型 ID 通过 VIDEO_MODEL_SEEDANCE、VIDEO_MODEL_KLING、VIDEO_MODEL_VEO3 配置。
diff --git a/web/app/page.tsx b/web/app/page.tsx
index b444755..48ae33f 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -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(null)
const [workbenchOpen, setWorkbenchOpen] = useState(false)
const [clipboard, setClipboard] = useState(null)
- const [videoDrafts, setVideoDrafts] = useState([])
const flowRef = useRef(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 | 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(
diff --git a/web/components/dashboard.tsx b/web/components/dashboard.tsx
index 366b092..4b74e92 100644
--- a/web/components/dashboard.tsx
+++ b/web/components/dashboard.tsx
@@ -623,8 +623,8 @@ export const Dashboard = forwardRef(function Dashboard({
{/* ---- VideoGen — Kanban ---- */}
{key === "videogen" && (
<>
-
- /v1/videos 待开通(IT)
+
+ 通过 /v1/videos 网关提交,模型 ID 走环境变量映射
字节跳动 · 需独立 API key
diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx
index 1de6f46..9052e29 100644
--- a/web/components/nodes/index.tsx
+++ b/web/components/nodes/index.tsx
@@ -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 (
- {drafts.length > 0 && (
+ {videos.length > 0 && (
- {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 (
-

+ {ready ? (
+
+ ) : posterSrc ? (
+

+ ) : (
+
+ )}
- {v.label}
- {v.provider}
+ 分镜 {v.frame_idx + 1}
+ {modelLabel(v.model)} · {v.status}
- {v.prompt}
+ {v.status === "failed" ? (v.error || "生成失败") : v.prompt}
- ))}
+ )})}
)}
}
title="生成视频 · Video Gen"
- subtitle={`STEP 7 · 首帧 + 动作 prompt${drafts.length > 0 ? ` · ${drafts.length} 个任务` : ""}`}
+ subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`}
selected={selected}
>
@@ -1022,9 +1071,9 @@ export function VideoGenNode({ data, selected }: any) {
))}
- {drafts.length > 0 && (
+ {videos.length > 0 && (
- 已生成 {drafts.length} 条视频 prompt · 点上方卡片复制
+ 已提交 {videos.length} 个视频任务 · 完成 {completed.length} 个{running ? " · 生成中" : ""}
)}
diff --git a/web/components/storyboard-workbench.tsx b/web/components/storyboard-workbench.tsx
index 44644d0..7bb41bc 100644
--- a/web/components/storyboard-workbench.tsx
+++ b/web/components/storyboard-workbench.tsx
@@ -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
}
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 | 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 (
- {/* 快速生成:先产出视频 prompt / 任务卡,结果显示到 Video Gen 节点 */}
+ {/* 快速生成:直接调用生视频 API,结果显示到 Video Gen 节点 */}
+
+
生成视频
+
+ {VIDEO_MODELS.map((m) => (
+
+ ))}
+
+
- 当前先生成可交付的视频 prompt / 任务卡并显示到 Video Gen 节点;后续接 Seedance / Kling / Veo 3 时直接替换为真实视频输出。
+ 用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 3367b58..5efb972 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -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 {
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 {
+ 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 {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
method: "DELETE",