From e79c33dabd079cf2910b0bd9ffed085559702160 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 13 May 2026 20:29:23 +0800 Subject: [PATCH] auto-save 2026-05-13 20:29 (~9) --- .memory/worklog.json | 7 ++ api/.env.example | 6 +- api/main.py | 157 +++++++++++++++++------- docs/source-analysis.html | 18 +-- web/app/page.tsx | 50 ++++---- web/components/dashboard.tsx | 4 +- web/components/nodes/index.tsx | 95 ++++++++++---- web/components/storyboard-workbench.tsx | 52 ++++++-- web/lib/api.ts | 46 ++++++- 9 files changed, 315 insertions(+), 120 deletions(-) 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 - 未来生成视频和合成成品。 - 当前只是占位,不要描述成已打通。 - VideoGenNodeComposeNode、未来模型接口 + 承载生视频任务状态和完成后的 MP4。 + 分镜工作台提交任务,Video Gen 节点只展示任务和结果。 + VideoGenNode/storyboard/videogenerated_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.tsxweb/components/nodes/index.tsxweb/app/page.tsxweb/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.pyapi/.env.exampleweb/components/storyboard-workbench.tsxweb/components/nodes/index.tsxweb/app/page.tsxweb/lib/api.ts。Sora 不再作为默认模型,真实模型 ID 通过 VIDEO_MODEL_SEEDANCEVIDEO_MODEL_KLINGVIDEO_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 ? ( +
    - ))} + )})}
    )} } 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",