diff --git a/RULES.md b/RULES.md
index 90cbdcb..6cb31fd 100644
--- a/RULES.md
+++ b/RULES.md
@@ -163,8 +163,8 @@
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`;旧环境若写 `minimax` 会被忽略
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
-- `POE_API_KEY` / `VIDEO_API_KEY`:默认视频生成通道 Key,只能放本地环境变量
-- `XAI_VIDEO_API_BASE_URL` / `XAI_VIDEO_API_KEY` / `VIDEO_MODEL_XAI`:xAI / Grok Imagine Video 独立视频通道;默认 base 为 `https://ai.skg.com/ezlink/xai`,模型为 `grok-imagine-video`,真实 key 只放本地 `api/.env`、本地 Docker `deploy/.env.local` 或服务器 `deploy/.env.production`,不入库。未配置 `XAI_VIDEO_API_KEY` 时 `/health` 会标记 xAI 视频不可用,画布不显示该模型。
+- `POE_API_KEY` / `VIDEO_API_KEY`:默认视频生成通道 Key,只能放本地环境变量;如果显式配置了 `VIDEO_API_BASE_URL`,必须同时配置 `VIDEO_API_KEY` 才会在 `/health` 暴露该默认视频通道,不能用通用 `LLM_API_KEY` 冒充视频 key。
+- `XAI_VIDEO_API_BASE_URL` / `XAI_VIDEO_API_KEY` / `VIDEO_MODEL_XAI`:xAI / Grok Imagine Video 独立视频通道;默认 base 为 `https://ai.skg.com/ezlink/xai`,模型为 `grok-imagine-video`,真实 key 只放本地 `api/.env`、本地 Docker `deploy/.env.local` 或服务器 `deploy/.env.production`,不入库。未配置 `XAI_VIDEO_API_KEY` 时 `/health` 会标记 xAI 视频不可用,画布不显示该模型;已配置时即使默认 Doubao/Seedance 视频 key 为空,也可以独立显示和生成 Grok Imagine Video。
- `PASSWORD_AUTH_ENABLED`:生产密码登录总开关;当前固定为 `false`,只允许飞书免登录。若应急恢复密码入口,必须显式改成 `true` 并重启 API。
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。当前密码入口被 `PASSWORD_AUTH_ENABLED=false` 禁用;即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。
diff --git a/api/.env.example b/api/.env.example
index 92e40e2..4392222 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -62,6 +62,7 @@ YTDLP_COOKIES_FROM_BROWSER=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=seedance-2-fast
VIDEO_MODEL_XAI=grok-imagine-video
+# Kling / Veo aliases are only exposed when the active video gateway is Poe or another verified compatible gateway.
VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast
diff --git a/api/main.py b/api/main.py
index e71910d..fe5c917 100644
--- a/api/main.py
+++ b/api/main.py
@@ -1524,6 +1524,8 @@ def video_api_key(model: str | None = None) -> str:
if "xai" in VIDEO_API_BASE_URL.lower() and VIDEO_API_KEY:
return VIDEO_API_KEY
return ""
+ if VIDEO_API_BASE_URL:
+ return VIDEO_API_KEY
if VIDEO_API_KEY:
return VIDEO_API_KEY
if video_uses_poe():
@@ -5145,6 +5147,8 @@ def video_model_options() -> list[dict]:
if key not in VIDEO_MODEL_ALIASES:
continue
model = VIDEO_MODEL_ALIASES[key]
+ if not _video_alias_can_use_current_gateway(key, model):
+ continue
if model in seen_models:
continue
seen_models.add(model)
@@ -5162,7 +5166,10 @@ def video_model_options() -> list[dict]:
"available": bool(video_api_key(model)),
})
default_model = resolve_video_model(VIDEO_MODEL)
- if not any(item["id"] == VIDEO_MODEL or item["model"] == default_model for item in options):
+ if (
+ _video_alias_can_use_current_gateway(VIDEO_MODEL, default_model)
+ and not any(item["id"] == VIDEO_MODEL or item["model"] == default_model for item in options)
+ ):
options.insert(0, {
"id": VIDEO_MODEL,
"label": label_map.get(VIDEO_MODEL, VIDEO_MODEL),
@@ -5179,6 +5186,41 @@ def video_model_options() -> list[dict]:
return options
+def _video_model_is_doubao_seedance(model: str | None) -> bool:
+ value = (model or "").strip().lower()
+ return value.startswith("doubao-seedance")
+
+
+def _video_alias_can_use_current_gateway(alias: str | None, model: str | None) -> bool:
+ key = (alias or "").strip().lower()
+ concrete = (model or "").strip()
+ concrete_lower = concrete.lower()
+ if is_xai_video_model(concrete):
+ return key in {"xai", "grok_imagine_video", "grok-imagine-video"} or concrete_lower == (XAI_VIDEO_MODEL or "").lower()
+ if video_uses_poe():
+ return not concrete_lower.startswith("doubao-")
+ if video_uses_ark(concrete):
+ return _video_model_is_doubao_seedance(concrete)
+ default_model = resolve_video_model(VIDEO_MODEL)
+ return concrete == default_model or key == (VIDEO_MODEL or "").strip().lower()
+
+
+def ensure_video_model_available(raw: str | None) -> str:
+ model = resolve_video_model(raw)
+ requested = (raw or VIDEO_MODEL or "").strip().lower()
+ matches = [
+ item for item in video_model_options()
+ if item.get("available") is not False
+ and (
+ str(item.get("id") or "").strip().lower() == requested
+ or str(item.get("model") or "").strip() == model
+ )
+ ]
+ if not matches:
+ raise HTTPException(400, "当前视频模型未接入或未配置,请选择 Seedance 2.0 Fast 或 Grok Imagine Video")
+ return model
+
+
def _image_failure_can_fallback(status_code: int, body: str, last_err: str) -> bool:
if status_code in (400, 401, 403, 404):
return False
@@ -9428,7 +9470,7 @@ def _enqueue_storyboard_videos(job: Job, frame: KeyFrame, req: GenerateStoryboar
reference_ref_paths.append(p)
seen_ref_paths.add(key)
- model = resolve_video_model(req.model)
+ model = ensure_video_model_available(req.model)
ensure_video_api_configured(model)
seconds = video_seconds(float(req.duration or 4), model)
video_size = _normalize_video_size(req.size, model)
diff --git a/deploy/.env.local.example b/deploy/.env.local.example
index e1512f6..626dfc9 100644
--- a/deploy/.env.local.example
+++ b/deploy/.env.local.example
@@ -76,8 +76,6 @@ VIDEO_API_KEY=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
VIDEO_MODEL_XAI=grok-imagine-video
-VIDEO_MODEL_KLING=kling-omni
-VIDEO_MODEL_VEO3=veo-3.1-fast
VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks
VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
diff --git a/deploy/.env.production.example b/deploy/.env.production.example
index f71c3b8..412329d 100644
--- a/deploy/.env.production.example
+++ b/deploy/.env.production.example
@@ -108,8 +108,6 @@ VIDEO_API_KEY=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
VIDEO_MODEL_XAI=grok-imagine-video
-VIDEO_MODEL_KLING=kling-omni
-VIDEO_MODEL_VEO3=veo-3.1-fast
VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks
VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 194b72d..6746f86 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -618,7 +618,7 @@
2026-05-25 三模式版:默认首页再收敛为一个中央对话框,首页和画布底部输入框只让用户选文生图、文生视频、图生视频,然后手写提示词生成。图生视频只显示“上传图片”,不再把首帧 / 首尾帧这类模型实现概念作为主入口;营销图文不再作为首页默认入口。后端 /health 返回可选图片 / 视频模型、图片尺寸、视频画幅和真实可用视频时长,首页按返回值显示模型和规格选择;当前 Doubao / Seedance 生产链路单条最长 15 秒,不向用户暴露 30 秒按钮。
2026-05-25 根域名画布版:https://marketing.skg.com 登录后直接进入个人生成画布,不再先进入 React 单对话框首页再点画布;/canvas/ 只保留为旧链接兼容跳转。后续优先少改成熟画布结构,只在必要时改模式文案、生成接入和结果/队列显示。
2026-05-25 上游能力恢复版:用户明确要求“API 没关系,其他恢复,别削弱”。因此根域名画布恢复 chatfire-AI/huobao-canvas 的成熟节点和工作流结构:推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置、多角度分镜、故事板、绘本和批量下载都保留;只继续替换品牌、路由和 API 接入。生成请求仍走 SKG 后端 /api 与登录 Cookie,员工不需要个人 API Key。
- 2026-05-25 媒体模型接入收口:图片和视频模型选择只暴露当前后端真实可用项:图片为 auto、gpt-image-2、gemini-3-pro-image-preview;视频已接通 Seedance 2.0 Fast(真实模型 doubao-seedance-2-0-fast-260128)和按独立 key 配置的 Grok Imagine Video(真实模型 grok-imagine-video)。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型。
+ 2026-05-25 媒体模型接入收口:图片和视频模型选择只暴露当前后端真实可用项:图片为 auto、gpt-image-2、gemini-3-pro-image-preview;视频已接通 Seedance 2.0 Fast(真实模型 doubao-seedance-2-0-fast-260128)和按独立 key 配置的 Grok Imagine Video(真实模型 grok-imagine-video)。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型;旧画布节点若保存了不可用视频模型,会在运行时模型清单加载后自动回退到当前可用项。
2026-05-26 公司沉淀版:画布项目从浏览器本地存储升级为服务端 Postgres 持久化;localStorage 只作为离线缓存和首次导入来源。后端同时建立用户、任务、资源索引和审计表,保留原有 state.json 文件作为任务详情真源,避免一次迁移动到大文件资产结构。
2026-05-26 AI 润色中性化:画布 AI 润色 不再复用 SKG 广告文案接口 /creative/copy。后端新增 POST /prompt/polish,前端 useChat、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。当前润色链路会先清理上一次润色遗留的模板尾巴,再判断人物/无人/物体/场景/动物/未知主体;原文明确有人时才声明虚构 AI 角色,原文明确无人时才保留无人物约束,原文没写人时不主动造人但也不追加“必须无人物”的模板尾巴;当输入或参考图已经有人物时,按 AI 生成的虚拟角色继续描述,而不是把人物参考图判定为不可用。
2026-05-26 我的工作流云端版:工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。
@@ -692,7 +692,7 @@
api/main.py | FastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_library 和 asset_library 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 state.json / 资源库并写入索引;/canvas-projects 系列接口把画布项目按当前登录用户持久化,/canvas-workflows 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;POST /prompt/polish 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 _strip_previous_polish_boilerplate 去掉旧模板尾巴,再用 _classify_prompt_intent 判断人物、无人、物体、场景、动物或未知主体,最后用 _repair_polished_prompt 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;_append_reference_image_person_guard 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;/health 返回 database、image_options、image_size_options、video_options、video_size_options、video_duration_options 和 video_max_duration_seconds;/frames/{idx}/generate 的 model 字段用于图片模型偏好,size 字段用于图片输出尺寸;/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界,然后把 GeneratedVideo 写成 queued 占位并进入进程内视频队列。队列默认 VIDEO_QUEUE_MAX_CONCURRENT=2、VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1,同一用户连续提交不会占满全局并发;排队任务会回写 queue_position、queue_size、queue_message。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 作为明确的 SKG 营销文案接口继续保留。 |
api/db.py | Postgres 适配层:在 DATABASE_URL 存在且 psycopg 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD、我的工作流 CRUD,以及把 Job、AgentRun、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled,生产 verify-prod-docker.sh 会要求 database.connected=true。 |
- video_model_options() | 视频模型能力出口:如果 seedance、kling、veo3、veo 等业务别名实际都映射到同一个真实模型,会按真实模型去重,只给前端返回一个可用选项;当前 Seedance 真实模型为 doubao-seedance-2-0-fast-260128,前端显示为 Seedance 2.0 Fast。新增 xai / grok-imagine-video 独立走 XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai、XAI_VIDEO_API_KEY、/v1/videos/generations 和 /v1/videos/{id},创建返回 request_id、轮询完成返回 video.url;未配置 xAI key 时 /health 会标记不可用,前端不显示。 |
+ video_model_options() | 视频模型能力出口:按当前视频网关过滤可真实路由的业务别名,Doubao / Ark 网关只暴露 doubao-seedance* 真实模型,Poe 网关才允许 Poe 的 Seedance / Kling / Veo 类模型;如果显式配置了 VIDEO_API_BASE_URL 但 VIDEO_API_KEY 为空,默认视频通道会标记不可用,不再回退通用 LLM_API_KEY。新增 xai / grok-imagine-video 独立走 XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai、XAI_VIDEO_API_KEY、/v1/videos/generations 和 /v1/videos/{id},创建返回 request_id、轮询完成返回 video.url;未配置 xAI key 时 /health 会标记不可用,前端不显示。 |
api/product_library/skg-products | 内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,images/ 存 45 张参考图。 |
api/character_library/skg-characters | 内置相似主体形象库:从桌面 5 套策划形象导入,manifest.json 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 prompt_brief。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。 |
asset_library/ | 全局素材库目录,和 jobs/ 平级,不写入任何 job state。四类目录为 subjects、products、scenes、videos;每个素材自带 manifest.json 和图片/视频文件,index.json 只是启动扫描重建出来的缓存。库素材选用到 job 时必须复制文件到 jobs/<jobId>/assets 或 storyboard-videos,禁止直接保存 library 引用。 |
@@ -1310,6 +1310,20 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-06-03 · 视频模型清单改为运行时真源
+ API
+ UI
+ Docs
+
+
+
问题:本地接入 Grok Imagine Video 后,/health 仍把 Doubao 网关下的 kling-omni、veo-3.1-fast 作为可用视频模型返回,旧画布节点保存的 Kling 会继续提交到错误网关并触发 400。
+
改动:api/main.py 的视频模型清单按当前网关过滤:Doubao / Ark 只暴露 doubao-seedance*,Poe 才暴露 Poe 的 Seedance / Kling / Veo;显式配置 VIDEO_API_BASE_URL 但未配置 VIDEO_API_KEY 时,默认视频通道不再借用 LLM_API_KEY 标记可用。视频提交入口新增可用模型校验,浏览器缓存硬塞的不可用模型会被本服务拦截。
+
前端:web/canvas-app/src/stores/pinia/models.js 在成功读取运行时模型后,以 /health 返回的可用视频模型替代静态视频清单;VideoConfigNode.vue 监听可用模型变化,旧节点若保存了不可用模型,会自动切回当前可用模型和对应清晰度。
+
影响:本地只配置 XAI_VIDEO_API_KEY 时,画布视频下拉只显示 Grok Imagine Video;同时配置有效 VIDEO_API_KEY 时才显示 Seedance。Kling / Veo 不会再因旧环境变量或旧缓存进入生成下拉。
+
+
2026-06-03 · 接入 xAI Grok Imagine Video
diff --git a/web/canvas-app/src/components/nodes/VideoConfigNode.vue b/web/canvas-app/src/components/nodes/VideoConfigNode.vue
index cec80ff..c6dab39 100644
--- a/web/canvas-app/src/components/nodes/VideoConfigNode.vue
+++ b/web/canvas-app/src/components/nodes/VideoConfigNode.vue
@@ -202,6 +202,7 @@ const localModel = ref(props.data?.model || DEFAULT_VIDEO_MODEL)
const localRatio = ref(props.data?.ratio || '16:9')
const localDuration = ref(props.data?.dur || 5)
const localResolution = ref(props.data?.resolution || currentModelDefaultResolution(props.data?.model || DEFAULT_VIDEO_MODEL))
+const availableVideoModels = computed(() => Array.isArray(modelStore.availableVideoModels) ? modelStore.availableVideoModels : [])
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
@@ -245,10 +246,11 @@ const imagesByRole = computed(() => {
// Get current model config | 获取当前模型配置
const currentModelConfig = computed(() => getModelConfig(localModel.value))
const isConfigured = computed(() => !!modelStore.currentApiKey)
-const canGenerate = computed(() => isConfigured.value && currentModelConfig.value?.available !== false)
+const currentModelAvailable = computed(() => availableVideoModels.value.some(model => model.key === localModel.value))
+const canGenerate = computed(() => isConfigured.value && currentModelAvailable.value && currentModelConfig.value?.available !== false)
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
-const modelOptions = computed(() => modelStore.allVideoModelOptions)
+const modelOptions = computed(() => modelStore.videoModelOptions)
// Display model name | 显示模型名称
const displayModelName = computed(() => {
@@ -277,7 +279,8 @@ const resolutionOptions = computed(() => {
})
// Handle model selection | 处理模型选择
-const handleModelSelect = (key) => {
+const applyModelSelection = (key) => {
+ if (!key) return
localModel.value = key
// Update ratio and duration to model's default | 更新为模型默认比例和时长
const config = getModelConfig(key)
@@ -296,6 +299,28 @@ const handleModelSelect = (key) => {
updateNode(props.id, updates)
}
+const syncModelWithAvailableOptions = () => {
+ const availableModels = availableVideoModels.value
+ if (!availableModels.length) return
+
+ const isModelAvailable = availableModels.some(model => model.key === localModel.value)
+ if (!localModel.value || !isModelAvailable) {
+ const selected = availableModels.find(model => model.key === modelStore.selectedVideoModel)?.key
+ applyModelSelection(selected || availableModels[0]?.key || DEFAULT_VIDEO_MODEL)
+ return
+ }
+
+ const nextResolution = normalizeResolutionForModel(localModel.value, localResolution.value)
+ if (nextResolution !== localResolution.value || !props.data?.resolution) {
+ localResolution.value = nextResolution
+ updateNode(props.id, { resolution: nextResolution })
+ }
+}
+
+const handleModelSelect = (key) => {
+ applyModelSelection(key)
+}
+
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newNodeId = duplicateNode(props.id)
@@ -530,23 +555,7 @@ const handleDelete = () => {
// Initialize on mount | 挂载时初始化
onMounted(() => {
- // 检查当前模型是否在可用模型列表中
- const availableModels = modelStore.availableVideoModels
- const isModelAvailable = availableModels.some(m => m.key === localModel.value)
-
- if (!localModel.value || !isModelAvailable) {
- // 使用 store 中的默认模型或第一个可用模型
- const selected = availableModels.find(m => m.key === modelStore.selectedVideoModel)?.key
- localModel.value = selected || availableModels[0]?.key || DEFAULT_VIDEO_MODEL
- localResolution.value = normalizeResolutionForModel(localModel.value, localResolution.value)
- updateNode(props.id, { model: localModel.value, resolution: localResolution.value })
- } else {
- const nextResolution = normalizeResolutionForModel(localModel.value, localResolution.value)
- if (nextResolution !== localResolution.value || !props.data?.resolution) {
- localResolution.value = nextResolution
- updateNode(props.id, { resolution: nextResolution })
- }
- }
+ syncModelWithAvailableOptions()
})
// Watch for model changes from props | 监听 props 中模型变化
@@ -554,9 +563,16 @@ watch(() => props.data?.model, (newModel) => {
if (newModel && newModel !== localModel.value) {
localModel.value = newModel
localResolution.value = normalizeResolutionForModel(newModel, props.data?.resolution || localResolution.value)
+ syncModelWithAvailableOptions()
}
})
+watch(
+ () => availableVideoModels.value.map(model => model.key).join('|'),
+ () => syncModelWithAvailableOptions(),
+ { immediate: true }
+)
+
watch(() => props.data?.resolution, (newResolution) => {
if (newResolution && newResolution !== localResolution.value) {
localResolution.value = normalizeResolutionForModel(localModel.value, newResolution)
diff --git a/web/canvas-app/src/stores/pinia/models.js b/web/canvas-app/src/stores/pinia/models.js
index 2319b7c..82ac727 100644
--- a/web/canvas-app/src/stores/pinia/models.js
+++ b/web/canvas-app/src/stores/pinia/models.js
@@ -264,6 +264,7 @@ export const useModelStore = defineStore('model', () => {
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, {}))
const runtimeImageModels = ref([])
const runtimeVideoModels = ref([])
+ const runtimeVideoModelsLoaded = ref(false)
// 选中的模型
const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL))
@@ -317,7 +318,9 @@ export const useModelStore = defineStore('model', () => {
)
const allVideoModels = computed(() =>
- mergeModels(VIDEO_MODELS, runtimeVideoModels.value)
+ runtimeVideoModelsLoaded.value
+ ? runtimeVideoModels.value
+ : mergeModels(VIDEO_MODELS, runtimeVideoModels.value)
)
// ============ Computed: Available Models (filtered by provider) ============
@@ -463,6 +466,10 @@ export const useModelStore = defineStore('model', () => {
.filter(item => item?.id && item.available !== false)
.map(normalizeRuntimeVideoModel)
.filter(Boolean)
+ runtimeVideoModelsLoaded.value = true
+ if (!availableVideoModels.value.some(model => model.key === selectedVideoModel.value)) {
+ selectedVideoModel.value = availableVideoModels.value[0]?.key || DEFAULT_VIDEO_MODEL
+ }
return true
} catch (err) {
console.warn('[model store] runtime model load failed', err)
@@ -513,7 +520,7 @@ export const useModelStore = defineStore('model', () => {
const image = allImageModels.value
.filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false }))
- const video = VIDEO_MODELS
+ const video = allVideoModels.value
.filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false }))
return { chat, image, video }
@@ -629,6 +636,7 @@ export const useModelStore = defineStore('model', () => {
allVideoModels,
runtimeImageModels,
runtimeVideoModels,
+ runtimeVideoModelsLoaded,
// Available models filtered by provider
availableChatModels,