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,容易误以为是某个分镜或模型选择错误。

    -

    改动:前端启动时读取 /healthmodels.video_configured;若为 false,分镜编排的生视频区域直接显示“视频 API 未开通”,并禁用提交按钮。

    -

    影响:web/lib/api.tsweb/app/page.tsxweb/components/storyboard-workbench.tsx

    +

    问题:虽然当前探测到常见视频入口返回 404/unsupported,但模型层确实有视频模型,不能在前端简单判定“未开通”并禁用。

    +

    改动:撤掉分镜编排里的前置禁用;后端允许提交 seedance / kling / veo / voe,并支持通过 VIDEO_CREATE_PATHS 逗号分隔配置多个候选生成入口,逐个尝试。

    +

    影响:api/main.pyapi/.env.exampleweb/app/page.tsxweb/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.tsweb/components/nodes/index.tsxapi/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.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 配置,真实视频 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 端点;这里先禁用提交,避免继续产生失败任务。 -
    - )}
    - {videoUnavailable - ? "需要 IT 给当前 key 分组开通 /videos,或配置外部 VIDEO_API_BASE_URL/VIDEO_API_KEY 后再生成。" - : "用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。"} + 用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。