diff --git a/.memory/worklog.json b/.memory/worklog.json
index cef7798..8b9ff85 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -2348,6 +2348,19 @@
"message": "auto-save 2026-05-13 20:40 (~4)",
"hash": "66f2495",
"files_changed": 4
+ },
+ {
+ "ts": "2026-05-13T20:45:53+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-13 20:45 (~6)",
+ "hash": "700fa24",
+ "files_changed": 6
+ },
+ {
+ "ts": "2026-05-13T12:49:30Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 7 项未提交变更 · 最近提交:auto-save 2026-05-13 20:45 (~6)",
+ "files_changed": 7
}
]
}
diff --git a/api/main.py b/api/main.py
index 6646556..c3a8ea5 100644
--- a/api/main.py
+++ b/api/main.py
@@ -216,7 +216,7 @@ def ensure_video_api_configured() -> None:
if not VIDEO_API_BASE_URL and "ai.skg.com/ezlink" in base:
raise HTTPException(
503,
- "当前 SKG ezlink 网关未开通生视频 /videos 端点;请配置 VIDEO_API_BASE_URL/VIDEO_API_KEY 接 Seedance、Kling 或 Veo 3 的真实视频 API 后再生成。",
+ "当前 SKG ezlink baseurl 已连通,但这把 key 未开通生视频 /videos 端点;需要 IT 给该分组开通视频端点,或改用真实 Seedance/Kling/Veo 3 视频 API。",
)
if not video_api_key():
raise HTTPException(503, "VIDEO_API_KEY 或 LLM_API_KEY 未配置,无法调用生视频 API")
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 47101ad..b3d420e 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -830,6 +830,30 @@ api/main.py
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-13 · 视频 API 未开通时前置禁用按钮
+ StoryboardWorkbench
+ Health
+
+
+
问题:当前 SKG ezlink 未开 /videos,用户点生成后才看到失败 toast,容易误以为是某个分镜或模型选择错误。
+
改动:前端启动时读取 /health 的 models.video_configured;若为 false,分镜编排的生视频区域直接显示“视频 API 未开通”,并禁用提交按钮。
+
影响:web/lib/api.ts、web/app/page.tsx、web/components/storyboard-workbench.tsx。
+
+
+
+
+ 2026-05-13 · 生视频错误提示改为可读原因
+ VideoGenNode
+ API
+
+
+
问题:提交生视频失败时,前端把 generateStoryboardVideo 503 {"detail": ...} 原样展示,用户无法快速判断是配置、端点还是 UI 问题。
+
改动:generateStoryboardVideo 解析后端 JSON 的 detail 后再抛错;后端 503 文案改为“SKG ezlink 已连通但当前 key 未开 /videos”;Video Gen 失败卡把 /videos 404 长错误压缩成一句可读原因。
+
影响:web/lib/api.ts、web/components/nodes/index.tsx、api/main.py。
+
+
2026-05-13 · Video Gen 卡片增加复制和删除
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 2b75b44..dfe7fcf 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -16,9 +16,9 @@ import { ThemeToggle } from "@/components/theme-toggle"
import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import {
- addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
+ addManualFrame, analyzeJob, createJob, getHealth, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, generateStoryboardVideo,
- type Job, type ImageRef, type StoryboardScene,
+ type BackendHealth, type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
@@ -77,6 +77,7 @@ export default function Home() {
const [storyboardFrame, setStoryboardFrame] = useState(null)
const [workbenchOpen, setWorkbenchOpen] = useState(false)
const [clipboard, setClipboard] = useState(null)
+ const [backendHealth, setBackendHealth] = useState(null)
const flowRef = useRef(null)
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
@@ -228,6 +229,10 @@ export default function Home() {
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
if (!job) return
+ if (backendHealth?.models?.video_configured === false) {
+ toast.error("当前 SKG ezlink 未开通生视频端点,不能提交视频任务")
+ return
+ }
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
@@ -265,7 +270,13 @@ export default function Home() {
} catch (e) {
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
}
- }, [job, setJob])
+ }, [job, setJob, backendHealth?.models?.video_configured])
+
+ useEffect(() => {
+ getHealth()
+ .then(setBackendHealth)
+ .catch(() => setBackendHealth(null))
+ }, [])
// URL ?job=xxx,yyy 自动恢复多个 job
useEffect(() => {
@@ -487,6 +498,7 @@ export default function Home() {
onJobUpdate={setJob as any}
clipboard={clipboard}
focusedFrame={storyboardFrame}
+ videoConfigured={backendHealth?.models?.video_configured}
onGenerateVideo={handleQuickGenerateVideo}
/>
diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx
index 3171ce6..de304ac 100644
--- a/web/components/nodes/index.tsx
+++ b/web/components/nodes/index.tsx
@@ -960,6 +960,13 @@ export function VideoGenNode({ data, selected }: any) {
if (m.includes("seedance")) return "Seedance"
return model || "Video"
}
+ const readableVideoError = (error?: string) => {
+ const e = error || "生成失败"
+ if (e.includes("/videos") && e.includes("404")) {
+ return "当前 SKG ezlink 未开通生视频 /videos 端点"
+ }
+ return e
+ }
return (
{videos.length > 0 && (
@@ -1075,7 +1082,7 @@ export function VideoGenNode({ data, selected }: any) {
{modelLabel(v.model)} · {v.status}
- {v.status === "failed" ? (v.error || "生成失败") : v.prompt}
+ {v.status === "failed" ? readableVideoError(v.error) : v.prompt}
diff --git a/web/components/storyboard-workbench.tsx b/web/components/storyboard-workbench.tsx
index 7bb41bc..1f2167c 100644
--- a/web/components/storyboard-workbench.tsx
+++ b/web/components/storyboard-workbench.tsx
@@ -15,6 +15,7 @@ interface Props {
onJobUpdate?: (j: Job) => void
clipboard: ImageRef | null // 全局剪贴板(page.tsx 提供)
focusedFrame: number | null
+ videoConfigured?: boolean
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void
}
@@ -28,7 +29,7 @@ const VIDEO_MODELS = [
{ value: "veo3", label: "Veo 3" },
] as const
-export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
+export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, videoConfigured, onGenerateVideo }: Props) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
@@ -124,6 +125,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"
+ const videoUnavailable = videoConfigured === false
return (
+ {videoUnavailable && (
+
+ 当前 SKG ezlink 只连通基础网关,但未开通生视频 /videos 端点;这里先禁用提交,避免继续产生失败任务。
+
+ )}
- 用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。
+ {videoUnavailable
+ ? "需要 IT 给当前 key 分组开通 /videos,或配置外部 VIDEO_API_BASE_URL/VIDEO_API_KEY 后再生成。"
+ : "用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。"}
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 9a629bd..c21f890 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -146,12 +146,33 @@ export interface Job {
error?: string
}
+export interface BackendHealth {
+ ok: boolean
+ llm_configured: boolean
+ base_url: string
+ models?: {
+ asr?: string
+ translate?: string
+ rewrite?: string
+ video?: string
+ video_aliases?: Record
+ video_base_url?: string
+ video_configured?: boolean
+ }
+}
+
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 getHealth(): Promise {
+ const res = await fetch(`${API_BASE}/health`)
+ if (!res.ok) throw new Error(`health ${res.status}`)
+ return res.json()
+}
+
export async function createJob(tkUrl: string): Promise {
const res = await fetch(`${API_BASE}/jobs`, {
method: "POST",
@@ -372,7 +393,12 @@ export async function generateStoryboardVideo(
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
- throw new Error(`generateStoryboardVideo ${res.status} ${txt.slice(0, 300)}`)
+ let detail = txt
+ try {
+ const parsed = JSON.parse(txt)
+ detail = parsed?.detail || txt
+ } catch {}
+ throw new Error(detail || `generateStoryboardVideo ${res.status}`)
}
return res.json()
}