diff --git a/.memory/worklog.json b/.memory/worklog.json
index 8b9ff85..1001da6 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -2361,6 +2361,13 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令:codex · 7 项未提交变更 · 最近提交:auto-save 2026-05-13 20:45 (~6)",
"files_changed": 7
+ },
+ {
+ "ts": "2026-05-13T20:51:23+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-13 20:51 (~7)",
+ "hash": "a8d0901",
+ "files_changed": 7
}
]
}
diff --git a/api/.env.example b/api/.env.example
index 218dfdd..345c97c 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -14,6 +14,7 @@ VIDEO_MODEL_VEO3=veo3
VIDEO_API_BASE_URL=
VIDEO_API_KEY=
VIDEO_CREATE_PATH=/videos
+VIDEO_CREATE_PATHS=/videos,/videos/generations,/video/generations
VIDEO_STATUS_PATH=/videos/{id}
VIDEO_CONTENT_PATH=/videos/{id}/content
VIDEO_DURATION_FIELD=seconds
diff --git a/api/main.py b/api/main.py
index c3a8ea5..a5a910f 100644
--- a/api/main.py
+++ b/api/main.py
@@ -35,10 +35,17 @@ VIDEO_MODEL_ALIASES = {
"seedance": os.getenv("VIDEO_MODEL_SEEDANCE", "seedance").strip() or "seedance",
"kling": os.getenv("VIDEO_MODEL_KLING", "kling").strip() or "kling",
"veo3": os.getenv("VIDEO_MODEL_VEO3", "veo3").strip() or "veo3",
+ "veo": os.getenv("VIDEO_MODEL_VEO3", "veo3").strip() or "veo3",
+ "voe": os.getenv("VIDEO_MODEL_VEO3", "veo3").strip() or "veo3",
}
VIDEO_API_BASE_URL = os.getenv("VIDEO_API_BASE_URL", "").strip()
VIDEO_API_KEY = os.getenv("VIDEO_API_KEY", "").strip()
VIDEO_CREATE_PATH = os.getenv("VIDEO_CREATE_PATH", "/videos").strip() or "/videos"
+VIDEO_CREATE_PATHS = [
+ p.strip()
+ for p in os.getenv("VIDEO_CREATE_PATHS", f"{VIDEO_CREATE_PATH},/videos/generations,/video/generations").split(",")
+ if p.strip()
+]
VIDEO_STATUS_PATH = os.getenv("VIDEO_STATUS_PATH", "/videos/{id}").strip() or "/videos/{id}"
VIDEO_CONTENT_PATH = os.getenv("VIDEO_CONTENT_PATH", "/videos/{id}/content").strip() or "/videos/{id}/content"
VIDEO_DURATION_FIELD = os.getenv("VIDEO_DURATION_FIELD", "seconds").strip() or "seconds"
@@ -210,14 +217,6 @@ def video_path(template: str, **values: str) -> str:
def ensure_video_api_configured() -> None:
- base = video_api_base()
- # 已探测:SKG ezlink 当前只开了 chat/images,/videos 返回 404。
- # 没有显式 VIDEO_API_BASE_URL 时,不再把这个 404 伪装成一次“生成失败”。
- if not VIDEO_API_BASE_URL and "ai.skg.com/ezlink" in base:
- raise HTTPException(
- 503,
- "当前 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")
@@ -790,7 +789,8 @@ def health() -> dict:
"video": VIDEO_MODEL,
"video_aliases": VIDEO_MODEL_ALIASES,
"video_base_url": video_api_base() if VIDEO_API_BASE_URL else "",
- "video_configured": bool(VIDEO_API_BASE_URL and video_api_key()),
+ "video_configured": bool(video_api_key()),
+ "video_create_paths": VIDEO_CREATE_PATHS,
},
}
@@ -1694,14 +1694,24 @@ def render_storyboard_video(job_id: str, local_id: str, provider_id: str, ref_pa
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}{video_path(VIDEO_CREATE_PATH)}",
- headers=headers,
- data=payload,
- files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
- )
- create.raise_for_status()
+ create = None
+ create_errors: list[str] = []
+ for create_path in VIDEO_CREATE_PATHS:
+ with ref_img.open("rb") as fh:
+ resp = client.post(
+ f"{base}{video_path(create_path)}",
+ headers=headers,
+ data=payload,
+ files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
+ )
+ if resp.status_code < 400:
+ create = resp
+ break
+ create_errors.append(f"{video_path(create_path)} -> HTTP {resp.status_code}: {resp.text[:160]}")
+ if resp.status_code not in {400, 404, 405}:
+ resp.raise_for_status()
+ if create is None:
+ raise RuntimeError("视频模型已选择,但当前网关视频生成入口不可用;已尝试 " + " | ".join(create_errors))
data = create.json()
video_api_id = data.get("id") or provider_id or local_id
status = normalize_video_status(data.get("status"))
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index b3d420e..e931182 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -790,7 +790,7 @@ api/main.py
ASR:SKG 网关 audio endpoint 404 或渠道不可用。
Translate:本身 text 通,但产品流里依赖 ASR 段落。
Rewrite:需要 SKG 产品信息模板和目标脚本结构。
- Video Gen:当前 SKG ezlink 未开 /videos(实测 404,/models 只列 sora-2);代码已支持通过 VIDEO_API_BASE_URL/VIDEO_API_KEY 显式接 Seedance / Kling / Veo 3 外部生视频 API,未配置时会前置报错,不再生成 5% 失败任务。
+ Video Gen:模型层按业务保留 Seedance / Kling / Veo/Voe 选择;网关调用层通过 VIDEO_CREATE_PATHS 多入口尝试,当前常见入口实测返回 404/unsupported,若平台后台有其它入口要直接配置到该变量。
Compose:还没做本地 ffmpeg 字幕/TTS 合成。
@@ -832,14 +832,14 @@ api/main.py
- 2026-05-13 · 视频 API 未开通时前置禁用按钮
+ 2026-05-13 · 生视频提交不再被前端锁死
StoryboardWorkbench
- Health
+ API
-
问题: 当前 SKG ezlink 未开 /videos,用户点生成后才看到失败 toast,容易误以为是某个分镜或模型选择错误。
-
改动: 前端启动时读取 /health 的 models.video_configured;若为 false,分镜编排的生视频区域直接显示“视频 API 未开通”,并禁用提交按钮。
-
影响: web/lib/api.ts、web/app/page.tsx、web/components/storyboard-workbench.tsx。
+
问题: 虽然当前探测到常见视频入口返回 404/unsupported,但模型层确实有视频模型,不能在前端简单判定“未开通”并禁用。
+
改动: 撤掉分镜编排里的前置禁用;后端允许提交 seedance / kling / veo / voe,并支持通过 VIDEO_CREATE_PATHS 逗号分隔配置多个候选生成入口,逐个尝试。
+
影响: api/main.py、api/.env.example、web/app/page.tsx、web/components/storyboard-workbench.tsx。
@@ -850,7 +850,7 @@ api/main.py
问题: 提交生视频失败时,前端把 generateStoryboardVideo 503 {"detail": ...} 原样展示,用户无法快速判断是配置、端点还是 UI 问题。
-
改动: generateStoryboardVideo 解析后端 JSON 的 detail 后再抛错;后端 503 文案改为“SKG ezlink 已连通但当前 key 未开 /videos”;Video Gen 失败卡把 /videos 404 长错误压缩成一句可读原因。
+
改动: generateStoryboardVideo 解析后端 JSON 的 detail 后再抛错;后端错误文案区分“模型存在”和“入口不可用”;Video Gen 失败卡把 /videos 404 长错误压缩成一句可读原因。
影响: web/lib/api.ts、web/components/nodes/index.tsx、api/main.py。
@@ -874,7 +874,7 @@ api/main.py
问题: 4 图槽已经粘贴参考图后,用户要直接调用生视频 API,而不是只生成 prompt 或图片任务。
-
改动: 分镜编排明细区增加 Seedance / Kling / Veo 3 模型选择和“调用模型生成视频”按钮;后端新增 /jobs/{job_id}/frames/{idx}/storyboard/video。若已配置真实 VIDEO_API_BASE_URL,则提交、轮询并保存 MP4;若仍使用当前 SKG ezlink,则前置返回 503,避免继续创建 404 失败任务。VideoGenNode 读取 job.generated_videos 展示排队、生成中、失败和完成视频。
+
改动: 分镜编排明细区增加 Seedance / Kling / Veo 3 模型选择和“调用模型生成视频”按钮;后端新增 /jobs/{job_id}/frames/{idx}/storyboard/video。提交后按 VIDEO_CREATE_PATHS 逐个尝试生成入口,成功后轮询并保存 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 配置,真实视频 API 地址通过 VIDEO_API_BASE_URL/VIDEO_API_KEY 配置。
diff --git a/web/app/page.tsx b/web/app/page.tsx
index dfe7fcf..2b75b44 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, getHealth, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
+ addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, generateStoryboardVideo,
- type BackendHealth, type Job, type ImageRef, type StoryboardScene,
+ 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 [backendHealth, setBackendHealth] = useState(null)
const flowRef = useRef(null)
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
@@ -229,10 +228,6 @@ 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
@@ -270,13 +265,7 @@ export default function Home() {
} catch (e) {
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
}
- }, [job, setJob, backendHealth?.models?.video_configured])
-
- useEffect(() => {
- getHealth()
- .then(setBackendHealth)
- .catch(() => setBackendHealth(null))
- }, [])
+ }, [job, setJob])
// URL ?job=xxx,yyy 自动恢复多个 job
useEffect(() => {
@@ -498,7 +487,6 @@ 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 de304ac..76ecf68 100644
--- a/web/components/nodes/index.tsx
+++ b/web/components/nodes/index.tsx
@@ -963,7 +963,7 @@ export function VideoGenNode({ data, selected }: any) {
const readableVideoError = (error?: string) => {
const e = error || "生成失败"
if (e.includes("/videos") && e.includes("404")) {
- return "当前 SKG ezlink 未开通生视频 /videos 端点"
+ return "模型已提交,但当前 /videos 入口返回 404;需要配置实际视频生成入口"
}
return e
}
diff --git a/web/components/storyboard-workbench.tsx b/web/components/storyboard-workbench.tsx
index 1f2167c..969a967 100644
--- a/web/components/storyboard-workbench.tsx
+++ b/web/components/storyboard-workbench.tsx
@@ -15,7 +15,6 @@ 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
}
@@ -26,10 +25,10 @@ const emptyScene = (): StoryboardScene => ({
const VIDEO_MODELS = [
{ value: "seedance", label: "Seedance" },
{ value: "kling", label: "Kling" },
- { value: "veo3", label: "Veo 3" },
+ { value: "veo3", label: "Veo / Voe" },
] as const
-export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, videoConfigured, onGenerateVideo }: Props) {
+export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
@@ -125,7 +124,6 @@ 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 端点;这里先禁用提交,避免继续产生失败任务。
-
- )}
{
if (focusedIdx === null) return
queueSave(form)
@@ -322,15 +315,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
}
}}
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={videoUnavailable ? "当前视频 API 未开通" : hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API,结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
+ title={hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API,结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
>
{generating ? : }
- {videoUnavailable ? "视频 API 未开通" : `调用 ${currentModelLabel} 生成视频`}
+ 调用 {currentModelLabel} 生成视频
- {videoUnavailable
- ? "需要 IT 给当前 key 分组开通 /videos,或配置外部 VIDEO_API_BASE_URL/VIDEO_API_KEY 后再生成。"
- : "用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。"}
+ 用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。