6 Commits

Author SHA1 Message Date
3f216727bb fix: download xai video outputs reliably 2026-06-03 23:21:22 +08:00
b1aab451ef docs: require real generation verification 2026-06-03 23:08:19 +08:00
ff0bfaa8b2 fix: gate video models by runtime availability 2026-06-03 17:48:49 +08:00
d038f1b2f4 feat: add xai video model 2026-06-03 16:59:43 +08:00
e14acee2a7 auto-save 2026-06-01 11:08 (~2) 2026-06-01 11:08:43 +08:00
538bfb8f59 chore: add huobao upstream watch 2026-06-01 11:06:24 +08:00
15 changed files with 907 additions and 436 deletions

View File

@@ -1,6 +1,6 @@
# 项目接力
- 生成时间May 30, 2026 at 01:26
- 生成时间May 31, 2026 at 15:25
- 项目SKG 营销内容生产平台
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
@@ -8,46 +8,55 @@
## 最近助手会话概览
- Claude2599ae96-47e6-4592-b382-3183737f9da3 · 时间未知
- Claudeeb894273-808b-439c-b4b9-840ad2553d0b · 时间未知
- Codex019e63ac-1984-7a42-8c65-ffd7ab146968 · 时间未知
## Claude 最近会话
- Session ID2599ae96-47e6-4592-b382-3183737f9da3
- Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/2599ae96-47e6-4592-b382-3183737f9da3.jsonl
- Session IDeb894273-808b-439c-b4b9-840ad2553d0b
- Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 你现在是在 Claude CLI 里的项目承接会话。 这不是对话立项台;项目目录已经存在,当前目标是接住上下文并等待用户下一步。 项目路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 承接步骤: - 先读取 `.memory/assistant-handoff.md` - 再阅读 `.project.json``RULES.md``AGENTS.md` - 结合当前仓库状态建立上下文 要求: - 不要要求用户重新粘贴 Claude / Codex / Cursor 的总结 - 先理解最近进度、未完成项和下一步候选 - 不要立即修改代码、继续上一个任务或自行执行命令 - 等待用户给出下一条明确指令后再开始动手 - 如果后续需要 Cursor提醒用户从看板打开 Cursor GUI 到当前项目目录
- 看下今天有哪些人使用了,分别怎么用
- <task-notification> <task-id>b0600axey</task-id> <tool-use-id>toolu_01WmFAqcK9tAmbAo5pyeYMHE</tool-use-id> <output-file>/private/tmp/claude-501/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b/tasks/b0600axey.output</output-file> <status>completed</status> <summary>Background command "构建并启动本地 Docker" completed (exit code 0)</summary> </task-notification>
- 继续
- <task-notification> <task-id>b2merdsp4</task-id> <tool-use-id>toolu_012dVaF8oA6bNGWwNtnMP95D</tool-use-id> <output-file>/private/tmp/claude-501/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b/tasks/b2merdsp4.output</output-file> <status>completed</status> <summary>Background command "生产安全部署(备份+rsync+服务器构建+验证)" completed (exit code 0)</summary> </task-notification>
### Claude 最近回复
> 时区对得上。开始查今天 2026-05-26CST的活跃用户和动作。
> Deploy fact recorded. `.project.json` needs no change (no URL changes this round). Committing the RULES.md update and pushing:
> 今天2026-05-26 CST一共有 **19 个账号活跃**18 个飞书员工 + 1 个 `password:[REDACTED]` 测试号),但真正在生成内容的只有 6-8 个人。下面按"用法"分组
> 完成。全部上线并验证
>
> ## 真在生产内容(创建 job + 出图/出视频)
> ## 最终交付
>
> | 用户 | job | 出图 | 出视频 | 画布项目 |
> |---|---|---|---|---|
> | **万康** | 66 creative | 14 张gpt-image-2 ×8 + gemini ×6| 11 成 / 8 失败 | 狗仔在奔跑 v38、SKG 颈部按摩仪竖屏广告 v55、示例项目 v37、雨天富声旁边花语 v18已删|
> | **姚智恒** | 13 creative | 11 张gpt ×6 + gemini ×5| 1 跑着 / 3 失败 | 日式街面美食摄影、未命名 |
> | **刘城** | 6 creative | 5 张gpt| — | 魔法森林、多角度分镜、未命名 ×2 |
> | **何展鹏** | 1 creative | — | 1 成 | 视频生视频、图生视频(智能参考)、未命名 ×2 |
> | **李玉成** | 2 creative | 1 张gpt| — | 雨中魔法森林、未命名 |
> | **梁燊悦** | 1 creative | 1 张gemini| — | "创建生产流水线生产SKG按摩仪的视频画面看起来高级点" |
> | **万亮** | 1 creative | — | — | 未命名(新手试一下)|
> **已推送** (Gitea, `4bcca76 → 22421eb`):
> - `3ed3f72` fix(api) · `b56d517` fix(canvas) · `6201ee9` fix(web) · `3572dde` chore · `22421eb` docs(deploy fact)
>
> 万康是绝对主力66 个 job、19 个视频、50 次画布导入),其他人都是浅试。**视频失败集中在 Doubao Seedance**(万康 8 失败、姚智恒 3 失败),值得回头看下失败原因(多半是 RULES.md 提到的隐私/人像风控)。
> **已部署生产** (`76.13.31.179:/opt/skg-marketing-studio`,`deploy-prod-safe.sh`):
> - 部署前备份:`skg-marketing-preserve-20260529181045.tgz`(+ pg_dump)
> - web/api 镜像在**服务器**构建成功(本地的 Google Fonts 失败纯属本机网络环境,与代码无关)
>
> ## 只动了画布,没真生成
> **生产验证**(`verify-prod-docker.sh` + 容器内复验):
>
> - **唐洛韬** — 建了"视频生成视频" / "图生成视频(智能)"两个画布4 次保存,没出图
> - **刘毅** — 建了"雨中魔法森…
> | 检查 | 结果 |
> |---|---|
> | web/api/postgres 容器 | Up |
> | `/` 302 · `/login/` 200 · `_next` 404 · `/api/health` 401 | ✅ |
> | `web:no_local_api_refs` · `api:ytdlp_cookie_args []` | ✅ |
> | `api:health ok db connected` | ✅ |
> | `psycopg_pool 3.2.4` 生效 | ✅ |
> | SSRF:`file://`/`169.254.169.254`/`evil.com` → 400,`tiktok.com` 放行 | ✅ |
> | `run()` timeout=300 · `DOWNLOAD_TIMEOUT_SECONDS=600` | ✅ |
>
> **元数据**:`RULES.md` 部署事实已回写并推送;`.project.json` 无 URL 变化,无需改。
>
> ## 上线后留意
>
> - **新增 env**(都有安全默认值,可不配):`…
## Codex 最近会话
@@ -86,7 +95,7 @@
- 当前分支main
- 未提交变更1 项
- 最近提交:feat: cache canvas media locally
- 最近提交:docs: record 2026-05-30 stability/security hardening deploy
- 变更文件:
- M .memory/worklog.json

View File

@@ -1,283 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"hash": "41e71d3",
"message": "docs: record fixed source workspace deployment",
"ts": "2026-05-20T21:33:53+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fixed source workspace deployment",
"ts": "2026-05-20T13:35:33Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "5d9d3d4",
"message": "auto-save 2026-05-20 21:43 (~2)",
"ts": "2026-05-20T21:43:41+08:00",
"type": "commit"
},
{
"files_changed": 2,
"hash": "d1e2b17",
"message": "fix: simplify conversion prompt confirmation",
"ts": "2026-05-20T21:45:17+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: simplify conversion prompt confirmation",
"ts": "2026-05-20T13:45:33Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"hash": "f1137cc",
"message": "docs: record conversion prompt deployment",
"ts": "2026-05-20T21:47:31+08:00",
"type": "commit"
},
{
"files_changed": 2,
"hash": "54f159b",
"message": "fix: stretch conversion layer height",
"ts": "2026-05-20T21:51:43+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: stretch conversion layer height",
"ts": "2026-05-20T13:55:33Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"hash": "06f3eb0",
"message": "docs: record stretched conversion deployment",
"ts": "2026-05-20T21:59:03+08:00",
"type": "commit"
},
{
"files_changed": 2,
"hash": "45b25d0",
"message": "fix: simplify generation composer",
"ts": "2026-05-20T22:04:27+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: simplify generation composer",
"ts": "2026-05-20T14:05:33Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"hash": "fc06816",
"message": "docs: record simplified composer deployment",
"ts": "2026-05-20T22:10:37+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record simplified composer deployment",
"ts": "2026-05-20T14:15:34Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "e1e9bf8",
"message": "fix: enlarge generation composer controls",
"ts": "2026-05-20T22:21:32+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "af76225",
"message": "docs: record enlarged composer deployment",
"ts": "2026-05-20T22:24:35+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record enlarged composer deployment",
"ts": "2026-05-20T14:25:34Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "215987a",
"message": "fix: lengthen conversion layer",
"ts": "2026-05-20T22:28:25+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "39b9d21",
"message": "docs: record conversion layer deployment",
"ts": "2026-05-20T22:34:40+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record conversion layer deployment",
"ts": "2026-05-20T14:35:34Z",
"type": "session-heartbeat"
},
{
"files_changed": 3,
"hash": "ab31a98",
"message": "fix: move generation confirm into composer",
"ts": "2026-05-20T22:42:08+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "9c05e0b",
"message": "docs: record composer generation deployment",
"ts": "2026-05-20T22:44:53+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record composer generation deployment",
"ts": "2026-05-20T14:45:34Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "2b842fd",
"message": "fix: remove fixed conversion layer height",
"ts": "2026-05-20T22:51:53+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: remove fixed conversion layer height",
"ts": "2026-05-20T14:55:34Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"hash": "dbedaba",
"message": "docs: record flexible conversion layout deployment",
"ts": "2026-05-20T22:56:47+08:00",
"type": "commit"
},
{
"files_changed": 2,
"hash": "bd14ae0",
"message": "auto-save 2026-05-20 23:05 (~2)",
"ts": "2026-05-20T23:05:06+08:00",
"type": "commit"
},
{
"files_changed": 2,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 2 项未提交变更 · 最近提交auto-save 2026-05-20 23:05 (~2)",
"ts": "2026-05-20T15:05:34Z",
"type": "session-heartbeat"
},
{
"files_changed": 3,
"hash": "77f9b5b",
"message": "fix: remove fixed board canvas scaling",
"ts": "2026-05-20T23:08:04+08:00",
"type": "commit"
},
{
"files_changed": 2,
"hash": "6597db3",
"message": "fix: remove fixed board canvas scaling",
"ts": "2026-05-20T23:10:03+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: remove fixed board canvas scaling",
"ts": "2026-05-20T15:15:34Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"hash": "1f193e9",
"message": "docs: record fluid board layout deployment",
"ts": "2026-05-20T23:20:23+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T15:25:35Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T15:35:35Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T15:45:35Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T15:55:35Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T16:05:35Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T17:04:17Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T17:14:17Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T17:24:17Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T17:34:17Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T17:44:17Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T17:54:18Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
"ts": "2026-05-20T18:04:18Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话结束 · 持续 0 秒 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record fluid board layout deployment",
@@ -3138,71 +2860,332 @@
"type": "assistant-session"
},
{
"ts": "2026-05-30T01:29:24+08:00",
"type": "commit",
"message": "auto-save 2026-05-30 01:29 (~2)",
"files_changed": 2,
"hash": "ea3cb37",
"files_changed": 2
"message": "auto-save 2026-05-30 01:29 (~2)",
"ts": "2026-05-30T01:29:24+08:00",
"type": "commit"
},
{
"ts": "2026-05-29T17:36:36Z",
"type": "session-heartbeat",
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交auto-save 2026-05-30 01:29 (~2)",
"files_changed": 1
"ts": "2026-05-29T17:36:36Z",
"type": "session-heartbeat"
},
{
"ts": "2026-05-30T01:46:06+08:00",
"type": "commit",
"message": "auto-save 2026-05-30 01:45 (~4)",
"files_changed": 4,
"hash": "514aabf",
"files_changed": 4
"message": "auto-save 2026-05-30 01:45 (~4)",
"ts": "2026-05-30T01:46:06+08:00",
"type": "commit"
},
{
"ts": "2026-05-29T17:46:36Z",
"type": "session-heartbeat",
"files_changed": 2,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 2 项未提交变更 · 最近提交auto-save 2026-05-30 01:45 (~4)",
"ts": "2026-05-29T17:46:36Z",
"type": "session-heartbeat"
},
{
"files_changed": 6,
"hash": "27e1cf9",
"message": "auto-save 2026-05-30 01:51 (~6)",
"ts": "2026-05-30T01:51:35+08:00",
"type": "commit"
},
{
"files_changed": 9,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 9 项未提交变更 · 最近提交auto-save 2026-05-30 01:51 (~6)",
"ts": "2026-05-29T17:56:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 9,
"hash": "5fc219a",
"message": "auto-save 2026-05-30 01:56 (~9)",
"ts": "2026-05-30T01:57:04+08:00",
"type": "commit"
},
{
"files_changed": 3,
"hash": "3ed3f72",
"message": "fix(api): harden subprocess/SSRF/concurrency and add db pool",
"ts": "2026-05-30T02:04:59+08:00",
"type": "commit"
},
{
"files_changed": 6,
"hash": "b56d517",
"message": "fix(canvas): persist video uploads and fix media cache/polling",
"ts": "2026-05-30T02:04:59+08:00",
"type": "commit"
},
{
"files_changed": 5,
"hash": "6201ee9",
"message": "fix(web): tolerant polling, objectURL cleanup, throttled pointermove",
"ts": "2026-05-30T02:04:59+08:00",
"type": "commit"
},
{
"files_changed": 2,
"hash": "3572dde",
"message": "chore: update session worklog/handoff",
"ts": "2026-05-30T02:04:59+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交chore: update session worklog/handoff",
"ts": "2026-05-29T18:06:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"hash": "22421eb",
"message": "docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-30T02:16:36+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T18:16:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T18:26:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T18:36:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T18:46:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T18:56:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T19:06:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T19:16:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T19:26:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T19:36:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T19:46:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T19:56:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T20:06:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T20:16:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T20:26:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T20:36:38Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T20:46:39Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T20:56:39Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T21:06:39Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T21:16:39Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T21:26:39Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T21:36:39Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T21:46:39Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T21:56:39Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T22:06:40Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T22:16:40Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T22:26:40Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T22:36:40Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T22:46:40Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T22:56:40Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T23:06:40Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T23:16:41Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T23:26:41Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T23:36:41Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T23:46:41Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-29T23:56:41Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Claude 会话结束 · 持续 0 秒 · 最近命令claude · 分支 main · 1 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-30T00:02:47Z",
"type": "session-end"
},
{
"files_changed": 2,
"message": "启动 Claude 接力会话 · 已载入 Claude / Codex 最近会话,等待下一条指令 · 分支 main · 2 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"ts": "2026-05-31T07:25:03Z",
"type": "assistant-session"
},
{
"ts": "2026-05-31T07:25:18Z",
"type": "session-end",
"message": "Claude 会话结束 · 持续 0 秒 · 最近命令claude · 分支 main · 2 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"files_changed": 2
},
{
"ts": "2026-05-30T01:51:35+08:00",
"type": "commit",
"message": "auto-save 2026-05-30 01:51 (~6)",
"hash": "27e1cf9",
"files_changed": 6
"ts": "2026-05-31T07:25:19Z",
"type": "session-end",
"message": "Claude 会话结束 · 持续 0 秒 · 最近命令claude · 分支 main · 2 项未提交变更 · 最近提交docs: record 2026-05-30 stability/security hardening deploy",
"files_changed": 2
},
{
"ts": "2026-05-29T17:56:37Z",
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 分支 main · 9 项未提交变更 · 最近提交auto-save 2026-05-30 01:51 (~6)",
"files_changed": 9
},
{
"ts": "2026-05-30T01:57:04+08:00",
"ts": "2026-06-01T11:06:24+08:00",
"type": "commit",
"message": "auto-save 2026-05-30 01:56 (~9)",
"hash": "5fc219a",
"files_changed": 9
},
{
"ts": "2026-05-30T02:04:59+08:00",
"type": "commit",
"message": "fix(api): harden subprocess/SSRF/concurrency and add db pool",
"hash": "3ed3f72",
"message": "chore: add huobao upstream watch",
"hash": "538bfb8",
"files_changed": 3
},
{
"ts": "2026-05-30T02:04:59+08:00",
"type": "commit",
"message": "fix(canvas): persist video uploads and fix media cache/polling",
"hash": "b56d517",
"files_changed": 6
},
{
"ts": "2026-05-30T02:04:59+08:00",
"type": "commit",
"message": "fix(web): tolerant polling, objectURL cleanup, throttled pointermove",
"hash": "6201ee9",
"files_changed": 5
}
]
}

View File

@@ -12,6 +12,7 @@
- 开发任务结束前必须执行并汇报 `git status -sb`
- 功能、修复、规则或部署元数据变更完成后,必须创建人工语义 commit`auto-save` 只算安全快照
- 默认先在本地 Docker 完整验证:`./scripts/start-local-docker.sh` 后运行 `./scripts/verify-local-docker.sh`;用户明确确认“可以推送 / 上推 / 部署”前,不要 `git push`,也不要运行生产部署脚本。
- 涉及模型接入、API Key、图片生成或视频生成链路修复时如果用户已经提供本地可用的 base/key 且没有明确禁止真实调用Agent 必须直接触发一次最小真实生成做端到端验证不能只停在配置检查、dry-run 或让用户手动点测;汇报时说明实际触发的模型、入口、任务 ID/生成 URL 或失败原因,以及是否产生了真实调用消耗。
- Gitea 是主远端,`origin` 必须指向 Gitea只有在用户明确确认推送后才把已验证的人工语义 commit 推送到 Gitea。
- 当前主分支为 `main`Gitea 仓库为 `https://git.kang-kang.com/kangwan/20260512-skg-tk`
- `.memory/worklog.json` 是辅助日志,不代替人工语义 commit 和 Gitea 远端记录

View File

@@ -3,6 +3,7 @@
## 启动
- 本地 Docker 启动:`./scripts/start-local-docker.sh`(默认 Web `http://localhost:4390`、API `http://localhost:4391`、Postgres 数据在 `data-local/postgres`;首次会从 `deploy/.env.local.example` 生成 gitignored 的 `deploy/.env.local`
- 本地 Docker 验证:`./scripts/verify-local-docker.sh`(检查容器、`/login/`、未登录 `/api/health`、容器内 `/health` + Postgres
- 本地真实生成验收涉及模型接入、API Key、图片生成或视频生成链路修复时已配置可用 base/key 且用户未明确禁止真实调用,就必须由 Agent 在本地 Docker / 当前画布中直接触发一次最小真实生成,记录模型、入口、任务 ID/生成 URL 或失败原因;不要只让用户自己手动点测。
- 本地 Docker 停止:`./scripts/stop-local-docker.sh`
- 后台启动(不弹 Terminal`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291日志写入 `.logs/`
- 后台停止:`./scripts/stop-dev-background.sh`
@@ -158,12 +159,13 @@
- `IMAGE_FALLBACK_ENABLED` / `IMAGE_FALLBACK_MODEL`:图片主模型故障兜底;当前允许在 `gpt-image-2` 超时、429、5xx 或网络错误时临时使用 `gemini-3-pro-image-preview`400/401/403/404 和参数错误不兜底
- `IMAGE_CIRCUIT_FAILURE_THRESHOLD` / `IMAGE_CIRCUIT_COOLDOWN_SECONDS`:短时熔断配置,默认 `gpt-image-2` 连续 2 次上游类失败后 600 秒内直接走 Gemini 兜底;成功恢复后自动清空失败计数
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名;主体 6 视图在转换层默认自动使用 `gpt-image-2`,同一套图内一旦触发 Gemini 兜底,后续视图沿用 Gemini避免一张张等待主模型超时用户显式选择 GPT 或 Gemini 时,`image_model_preference` 会让主体套图只走所选模型
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图或生视频 MP4 下载报 DNS / ConnectError / SSL 握手异常,可在本地 `api/.env` 配置后重启后端。本地 Docker 使用 `deploy/.env.local`,宿主机代理要写成 `http://host.docker.internal:端口`,不能写容器内的 `127.0.0.1``/health` 只回传是否配置代理,不回传代理地址。
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;生产云端固定使用 cookies 文件 `/run/secrets/tiktok_cookies.txt`(宿主机 `./secrets/tiktok_cookies.txt` 挂载进容器),本地开发可临时用浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
- `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只能放本地环境变量
- `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`,不入库。

View File

@@ -61,6 +61,8 @@ YTDLP_COOKIES_FILE=
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
@@ -96,6 +98,13 @@ POE_API_KEY=
# VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
# VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
#
# SKG xAI/Grok Imagine 视频网关。真实 key 只填本地/服务器私有 .env。
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
XAI_VIDEO_CONTENT_PATH=
#
# 自定义视频网关覆盖;留空时如配置 POE_API_KEY则走 Poe。
VIDEO_API_BASE_URL=
VIDEO_API_KEY=

View File

@@ -350,9 +350,31 @@ VIDEO_MODEL_ALIASES = {
"veo3": env_video_model("VIDEO_MODEL_VEO3", "veo-3.1-fast"),
"veo": env_video_model("VIDEO_MODEL_VEO3", "veo-3.1-fast"),
"voe": env_video_model("VIDEO_MODEL_VEO3", "veo-3.1-fast"),
"grok_imagine_video": env_video_model("VIDEO_MODEL_XAI", "grok-imagine-video"),
"grok-imagine-video": env_video_model("VIDEO_MODEL_XAI", "grok-imagine-video"),
"xai": env_video_model("VIDEO_MODEL_XAI", "grok-imagine-video"),
}
VIDEO_API_BASE_URL = os.getenv("VIDEO_API_BASE_URL", "").strip()
VIDEO_API_KEY = os.getenv("VIDEO_API_KEY", "").strip()
_VIDEO_XAI_BASE_DEFAULT = (
VIDEO_API_BASE_URL
if "xai" in VIDEO_API_BASE_URL.lower()
else "https://ai.skg.com/ezlink/xai"
)
XAI_VIDEO_API_BASE_URL = (
os.getenv("XAI_VIDEO_API_BASE_URL")
or os.getenv("XAI_GATEWAY_BASE")
or _VIDEO_XAI_BASE_DEFAULT
).strip().rstrip("/")
XAI_VIDEO_API_KEY = (
os.getenv("XAI_VIDEO_API_KEY")
or os.getenv("XAI_GATEWAY_KEY")
or (VIDEO_API_KEY if "xai" in VIDEO_API_BASE_URL.lower() else "")
).strip()
XAI_VIDEO_MODEL = VIDEO_MODEL_ALIASES["xai"]
XAI_VIDEO_CREATE_PATH = os.getenv("XAI_VIDEO_CREATE_PATH", "/v1/videos/generations").strip() or "/v1/videos/generations"
XAI_VIDEO_STATUS_PATH = os.getenv("XAI_VIDEO_STATUS_PATH", "/v1/videos/{id}").strip() or "/v1/videos/{id}"
XAI_VIDEO_CONTENT_PATH = os.getenv("XAI_VIDEO_CONTENT_PATH", "").strip()
WEB_AUTH_USERNAME = os.getenv("WEB_AUTH_USERNAME", "").strip()
WEB_AUTH_PASSWORD = os.getenv("WEB_AUTH_PASSWORD", "").strip()
WEB_AUTH_SESSION_SECRET = os.getenv("WEB_AUTH_SESSION_SECRET", "").strip()
@@ -389,6 +411,12 @@ WEB_AUTH_CONFIGURED = bool(PASSWORD_AUTH_CONFIGURED or FEISHU_AUTH_CONFIGURED)
def default_video_gateway_paths(base_url: str) -> tuple[str, str, str]:
base = base_url.strip().rstrip("/").lower()
if "api.x.ai" in base or "/ezlink/xai" in base:
return (
"/v1/videos/generations",
"/v1/videos/{id}",
"",
)
if "ai.skg.com/doubao" in base:
return (
"/api/v3/contents/generations/tasks",
@@ -1446,13 +1474,30 @@ def video_uses_poe() -> bool:
return bool(POE_API_KEY)
def video_uses_ark() -> bool:
base = video_api_base()
def is_xai_video_model(model: str | None) -> bool:
value = (model or "").strip().lower()
if not value:
value = (VIDEO_MODEL or "").strip().lower()
resolved = VIDEO_MODEL_ALIASES.get(value, value).strip().lower()
xai_model = (XAI_VIDEO_MODEL or "grok-imagine-video").strip().lower()
return resolved == xai_model or resolved.startswith("grok-imagine-video")
def video_uses_xai(model: str | None = None) -> bool:
return is_xai_video_model(model) or "api.x.ai" in video_api_base(model).lower() or "/ezlink/xai" in video_api_base(model).lower()
def video_uses_ark(model: str | None = None) -> bool:
if video_uses_xai(model):
return False
base = video_api_base(model)
return "ark.cn-beijing.volces.com" in base or "ai.skg.com/doubao" in base
def video_provider_name() -> str:
base = video_api_base()
def video_provider_name(model: str | None = None) -> str:
base = video_api_base(model)
if video_uses_xai(model):
return "xai"
if video_uses_poe():
return "poe"
if "ai.skg.com/doubao" in base:
@@ -1462,7 +1507,9 @@ def video_provider_name() -> str:
return "custom"
def video_api_base() -> str:
def video_api_base(model: str | None = None) -> str:
if is_xai_video_model(model):
return XAI_VIDEO_API_BASE_URL.rstrip("/")
if VIDEO_API_BASE_URL:
return VIDEO_API_BASE_URL.rstrip("/")
if POE_API_KEY:
@@ -1470,7 +1517,15 @@ def video_api_base() -> str:
return (LLM_BASE_URL or "https://api.openai.com/v1").rstrip("/")
def video_api_key() -> str:
def video_api_key(model: str | None = None) -> str:
if is_xai_video_model(model):
if XAI_VIDEO_API_KEY:
return XAI_VIDEO_API_KEY
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():
@@ -1478,14 +1533,26 @@ def video_api_key() -> str:
return LLM_API_KEY
def video_create_paths(model: str | None = None) -> list[str]:
return [XAI_VIDEO_CREATE_PATH] if video_uses_xai(model) else VIDEO_CREATE_PATHS
def video_status_path(model: str | None = None) -> str:
return XAI_VIDEO_STATUS_PATH if video_uses_xai(model) else VIDEO_STATUS_PATH
def video_content_path(model: str | None = None) -> str:
return XAI_VIDEO_CONTENT_PATH if video_uses_xai(model) else VIDEO_CONTENT_PATH
def video_path(template: str, **values: str) -> str:
path = template.format(**values)
return path if path.startswith("/") else f"/{path}"
def ensure_video_api_configured() -> None:
if not video_api_key():
raise HTTPException(503, "POE_API_KEY、VIDEO_API_KEY 或 LLM_API_KEY 未配置,无法调用生视频 API")
def ensure_video_api_configured(model: str | None = None) -> None:
if not video_api_key(model):
raise HTTPException(503, "POE_API_KEY、VIDEO_API_KEY、XAI_VIDEO_API_KEY 或 LLM_API_KEY 未配置,无法调用生视频 API")
def storyboard_ref_path(job_id: str, ref: dict | None) -> Path | None:
@@ -4973,13 +5040,16 @@ def _image_size_payload(raw: str | None, model: str | None = None) -> dict:
return {} if size == "auto" else {"size": size}
def video_duration_options() -> list[int]:
if video_uses_ark():
def video_duration_options(model: str | None = None) -> list[int]:
if video_uses_ark(model) or video_uses_xai(model):
return [5, 8, 10, 12, 15]
return [4, 8, 12]
def video_size_options() -> list[dict]:
def video_size_options(model: str | None = None) -> list[dict]:
if video_uses_xai(model):
allowed = {"720x1280", "1280x720", "1024x1024"}
return [item for item in VIDEO_SIZE_CHOICES if str(item["value"]) in allowed]
return VIDEO_SIZE_CHOICES
@@ -4992,7 +5062,9 @@ def _video_resolution_choice(value: str) -> dict:
def _video_resolution_values_for_model(model: str | None) -> list[str]:
concrete = (model or "").strip().lower()
if video_uses_ark():
if video_uses_xai(concrete):
return ["480p", "720p"]
if video_uses_ark(concrete):
if "seedance-2-0-fast" in concrete:
return ["480p", "720p"]
if "seedance-2-0" in concrete or "seedance-1-5-pro" in concrete or "seedance-1-0-pro" in concrete:
@@ -5029,7 +5101,7 @@ def _normalize_video_resolution(raw: str | None, model: str | None = None) -> st
return value
def _normalize_video_size(raw: str | None) -> str:
def _normalize_video_size(raw: str | None, model: str | None = None) -> str:
value = (raw or "720x1280").strip().lower().replace(" ", "")
aliases = {
"vertical": "720x1280",
@@ -5046,7 +5118,7 @@ def _normalize_video_size(raw: str | None) -> str:
"3:4": "960x1280",
}
value = aliases.get(value, value)
allowed = {str(item["value"]) for item in VIDEO_SIZE_CHOICES}
allowed = {str(item["value"]) for item in video_size_options(model)}
if value not in allowed:
raise HTTPException(400, f"unsupported video size: {raw}")
return value
@@ -5060,17 +5132,23 @@ def video_model_options() -> list[dict]:
"veo3": "Veo 3",
"veo": "Veo",
"voe": "Veo",
"xai": "Grok Imagine Video",
"grok_imagine_video": "Grok Imagine Video",
"grok-imagine-video": "Grok Imagine Video",
}
concrete_label_map = {
"doubao-seedance-2-0-fast-260128": "Seedance 2.0 Fast",
"doubao-seedance-2-0-260128": "Seedance 2.0 高清",
"grok-imagine-video": "Grok Imagine Video",
}
seen_models: set[str] = set()
options: list[dict] = []
for key in ["seedance", "seedance_hd", "kling", "veo3", "veo"]:
for key in ["seedance", "seedance_hd", "xai", "kling", "veo3", "veo"]:
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)
@@ -5078,31 +5156,71 @@ def video_model_options() -> list[dict]:
"id": key,
"label": concrete_label_map.get(model, label_map.get(key, key)),
"model": model,
"description": f"当前视频网关可选模型;单次时长最高 {max(video_duration_options())}",
"duration_options": video_duration_options(),
"size_options": video_size_options(),
"provider": video_provider_name(model),
"description": f"当前视频网关可选模型;单次时长最高 {max(video_duration_options(model))}",
"duration_options": video_duration_options(model),
"size_options": video_size_options(model),
"resolution_options": video_resolution_options(model),
"default_resolution": default_video_resolution(model),
"max_duration_seconds": max(video_duration_options()),
"available": bool(video_api_key()),
"max_duration_seconds": max(video_duration_options(model)),
"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),
"model": default_model,
"provider": video_provider_name(default_model),
"description": "默认视频模型",
"duration_options": video_duration_options(),
"size_options": video_size_options(),
"duration_options": video_duration_options(default_model),
"size_options": video_size_options(default_model),
"resolution_options": video_resolution_options(default_model),
"default_resolution": default_video_resolution(default_model),
"max_duration_seconds": max(video_duration_options()),
"available": bool(video_api_key()),
"max_duration_seconds": max(video_duration_options(default_model)),
"available": bool(video_api_key(default_model)),
})
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
@@ -6585,6 +6703,10 @@ def health() -> dict:
"video_base_url": video_api_base(),
"video_configured": bool(video_api_key()),
"video_create_paths": VIDEO_CREATE_PATHS,
"xai_video_model": XAI_VIDEO_MODEL,
"xai_video_base_url": XAI_VIDEO_API_BASE_URL,
"xai_video_configured": bool(video_api_key(XAI_VIDEO_MODEL)),
"xai_video_create_path": XAI_VIDEO_CREATE_PATH,
},
}
@@ -8832,8 +8954,8 @@ class ProductFusionDescriptionReq(BaseModel):
shots: list[ProductFusionShot] = Field(default_factory=list)
def video_seconds(duration: float) -> str:
if video_uses_ark():
def video_seconds(duration: float, model: str | None = None) -> str:
if video_uses_ark(model) or video_uses_xai(model):
if duration <= 0:
return "5"
return str(max(4, min(15, round(duration))))
@@ -8848,7 +8970,7 @@ 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")
raise HTTPException(400, "Sora 已停用,请选择当前已接入的 Seedance 或 Grok Imagine Video")
return VIDEO_MODEL_ALIASES.get(lowered, requested)
@@ -8897,6 +9019,12 @@ def video_url_from_response(data: dict) -> str:
v = content.get(key)
if isinstance(v, str) and v:
return v
video = data.get("video")
if isinstance(video, dict):
for key in ("url", "video_url", "download_url", "file_url"):
v = video.get(key)
if isinstance(v, str) and v:
return v
return ""
@@ -8904,6 +9032,25 @@ def _video_public_error(raw: object) -> str:
text = str(raw or "").strip()
lower = text.lower()
if any(token in lower for token in (
"name or service not known",
"temporary failure in name resolution",
"nodename nor servname",
"connection refused",
"network is unreachable",
"connecterror",
"connecttimeout",
"readtimeout",
"ssl:",
"_ssl.c",
"handshake",
"unexpected_eof",
"eof occurred",
"网络",
"dns",
)):
return "视频生成失败:服务器连接视频模型网关异常,请稍后重试;如果连续失败,请联系管理员检查视频网关网络。"
if any(token.lower() in lower for token in (
"InputImageSensitiveContentDetected.PrivacyInformation".lower(),
"privacyinformation",
@@ -8987,13 +9134,32 @@ def _video_create_failure_message(create_errors: list[str]) -> str:
return "视频生成失败:视频模型没有接受本次请求。请换一张参考图或简化提示词后重试;如果持续失败,请联系管理员。"
def download_generated_video(client, base: str, headers: dict, provider_id: str, direct_url: str, out_mp4: Path) -> None:
def download_generated_video(client, base: str, headers: dict, provider_id: str, direct_url: str, out_mp4: Path, model: str | None = None) -> None:
last_error: Exception | None = None
for attempt in range(4):
try:
_download_generated_video_once(client, base, headers, provider_id, direct_url, out_mp4, model)
return
except Exception as e:
last_error = e
if attempt >= 3:
break
time.sleep(2 + attempt * 3)
raise last_error or RuntimeError("视频生成完成但下载失败")
def _download_generated_video_once(client, base: str, headers: dict, provider_id: str, direct_url: str, out_mp4: Path, model: str | None = None) -> 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)
r = client.get(url, headers=headers if url.startswith(base) else None, follow_redirects=True)
else:
r = client.get(f"{base}{video_path(VIDEO_CONTENT_PATH, id=provider_id)}", headers=headers)
content_path = video_content_path(model)
if not content_path:
raise RuntimeError("视频生成完成但未返回可下载地址")
r = client.get(f"{base}{video_path(content_path, id=provider_id)}", headers=headers, follow_redirects=True)
r.raise_for_status()
if not r.content:
raise RuntimeError("视频生成完成但下载内容为空")
out_mp4.write_bytes(r.content)
@@ -9032,7 +9198,33 @@ def submit_video_create(
product_imgs: list[Path] | None = None,
primary_role: str = "first_frame",
):
if video_uses_ark():
model = str(payload.get("model") or "")
if video_uses_xai(model):
duration = int(float(str(payload.get("duration") or payload.get(VIDEO_DURATION_FIELD) or 8)))
data: dict = {
"model": model,
"prompt": payload["prompt"],
"duration": max(1, duration),
"aspect_ratio": size_to_video_ratio(str(payload.get("size", ""))),
"resolution": _normalize_video_resolution(str(payload.get("resolution") or ""), model),
}
reference_images: list[dict] = []
if ref_img.exists() and primary_role:
ref_payload = {"url": ark_reference_data_url(ref_img)}
if primary_role == "first_frame":
data["image"] = ref_payload
else:
reference_images.append(ref_payload)
if last_img and last_img.exists():
reference_images.append({"url": ark_reference_data_url(last_img)})
for product_img in (product_imgs or [])[:6]:
if product_img.exists():
reference_images.append({"url": ark_reference_data_url(product_img)})
if reference_images:
data["reference_images"] = reference_images[:6]
return client.post(url, headers={**headers, "Content-Type": "application/json"}, json=data)
if video_uses_ark(model):
content = [{"type": "text", "text": payload["prompt"]}]
if source_ref and source_ref.kind == "source_video" and source_ref.url:
content.append(
@@ -9046,7 +9238,7 @@ def submit_video_create(
{
"type": "image_url",
"image_url": {"url": ark_reference_data_url(ref_img)},
"role": primary_role,
"role": primary_role or "reference_image",
}
)
if last_img and last_img.exists():
@@ -9112,8 +9304,8 @@ def render_storyboard_video(
ref_img = out_dir / "reference.jpg"
last_img = out_dir / "last_reference.jpg"
out_mp4 = out_dir / "video.mp4"
base = video_api_base()
headers = {"Authorization": f"Bearer {video_api_key()}"}
base = video_api_base(model)
headers = {"Authorization": f"Bearer {video_api_key(model)}"}
try:
prepare_video_reference(ref_path, ref_img)
@@ -9128,20 +9320,20 @@ def render_storyboard_video(
prepare_video_reference(product_ref_path, product_img)
prepared_product_imgs.append(product_img)
update_generated_video(job_id, local_id, status="in_progress", progress=5, queue_message="准备素材…")
with httpx.Client(timeout=120) as client:
with ai_http_client(timeout=300) as client:
payload = {"model": model, "prompt": prompt, "size": size, "resolution": resolution}
payload[VIDEO_DURATION_FIELD] = seconds
create = None
create_errors: list[str] = []
for create_path in VIDEO_CREATE_PATHS:
for create_path in video_create_paths(model):
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs, primary_role)
if video_uses_ark() and source_ref and resp.status_code in {400, 422}:
if video_uses_ark(model) and source_ref and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + reference_video -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs, primary_role)
if video_uses_ark() and prepared_last_img and resp.status_code in {400, 422}:
if video_uses_ark(model) and prepared_last_img and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + last_frame -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_imgs, primary_role)
if video_uses_ark() and prepared_product_imgs and resp.status_code in {400, 422}:
if video_uses_ark(model) and prepared_product_imgs and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + product_reference -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, None, primary_role)
if resp.status_code < 400:
@@ -9154,7 +9346,7 @@ def render_storyboard_video(
print(f"[video create failed] job={job_id} video={local_id} errors={' | '.join(create_errors)[:1800]}", flush=True)
raise RuntimeError(_video_create_failure_message(create_errors))
data = create.json()
video_api_id = data.get("id") or provider_id or local_id
video_api_id = data.get("request_id") or 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)
@@ -9171,7 +9363,7 @@ def render_storyboard_video(
deadline = time.time() + VIDEO_POLL_TIMEOUT_SECONDS
while status in {"queued", "in_progress"} and time.time() < deadline:
time.sleep(8)
poll = client.get(f"{base}{video_path(VIDEO_STATUS_PATH, id=video_api_id)}", headers=headers)
poll = client.get(f"{base}{video_path(video_status_path(model), id=video_api_id)}", headers=headers)
poll.raise_for_status()
pdata = poll.json()
status = normalize_video_status(pdata.get("status"))
@@ -9200,7 +9392,7 @@ def render_storyboard_video(
update_generated_video(job_id, local_id, status="failed", error=_video_public_error(raw_error or f"video status: {status}"), progress=progress, queue_message="")
return
download_generated_video(client, base, headers, video_api_id, direct_url, out_mp4)
download_generated_video(client, base, headers, video_api_id, direct_url, out_mp4, model)
update_generated_video(
job_id,
local_id,
@@ -9286,7 +9478,6 @@ def refine_storyboard(job_id: str, idx: int, req: RefineStoryboardReq) -> dict:
def _enqueue_storyboard_videos(job: Job, frame: KeyFrame, req: GenerateStoryboardVideoReq, bg: BackgroundTasks | None = None) -> list[str]:
ensure_video_api_configured()
prompt = _ensure_english(req.prompt.strip())
if not prompt and frame.storyboard:
prompt = _storyboard_video_prompt(frame.storyboard, req.seed)
@@ -9295,7 +9486,7 @@ def _enqueue_storyboard_videos(job: Job, frame: KeyFrame, req: GenerateStoryboar
count = max(1, min(12, int(req.count or 1)))
ref = req.first_image or req.subject_image or req.product_image or req.scene_image or req.action_image
primary_role = "first_frame" if req.first_image else "reference_image"
primary_role = "first_frame" if req.first_image else ("reference_image" if ref else "")
ref_path = storyboard_ref_path(job.id, ref) or (job_dir(job.id) / "frames" / f"{frame.index:03d}.jpg")
if not ref_path.exists():
raise HTTPException(404, "reference image missing")
@@ -9314,14 +9505,24 @@ 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)
seconds = video_seconds(float(req.duration or 4))
video_size = _normalize_video_size(req.size)
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)
video_resolution = _normalize_video_resolution(req.resolution, model)
source_ref = req.source_ref
if source_ref and source_ref.kind == "source_video" and not source_ref.url:
source_ref = None
has_visual_reference = bool(ref_path.exists() or last_ref_path or reference_ref_paths)
has_visual_reference = bool(
req.first_image
or req.subject_image
or req.product_image
or req.scene_image
or req.action_image
or req.last_image
or raw_product_refs
or req.subject_images
)
items: list[GeneratedVideo] = []
ids: list[str] = []
queued_tasks: list[tuple[str, tuple]] = []

View File

@@ -75,13 +75,17 @@ VIDEO_API_BASE_URL=https://ai.skg.com/doubao
VIDEO_API_KEY=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast
VIDEO_MODEL_XAI=grok-imagine-video
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
VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
XAI_VIDEO_CONTENT_PATH=
# Azure OpenAI TTS. Leave blank unless testing voice locally.
AUDIO_REWRITE_MODEL=gemini-2.5-pro

View File

@@ -107,10 +107,14 @@ VIDEO_API_BASE_URL=https://ai.skg.com/doubao
VIDEO_API_KEY=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast
VIDEO_MODEL_XAI=grok-imagine-video
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
VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
XAI_VIDEO_CONTENT_PATH=

View File

@@ -618,7 +618,7 @@
<p><strong>2026-05-25 三模式版:</strong>默认首页再收敛为一个中央对话框,首页和画布底部输入框只让用户选文生图、文生视频、图生视频,然后手写提示词生成。图生视频只显示“上传图片”,不再把首帧 / 首尾帧这类模型实现概念作为主入口;营销图文不再作为首页默认入口。后端 <code>/health</code> 返回可选图片 / 视频模型、图片尺寸、视频画幅和真实可用视频时长,首页按返回值显示模型和规格选择;当前 Doubao / Seedance 生产链路单条最长 15 秒,不向用户暴露 30 秒按钮。</p>
<p><strong>2026-05-25 根域名画布版:</strong><code>https://marketing.skg.com</code> 登录后直接进入个人生成画布,不再先进入 React 单对话框首页再点画布;<code>/canvas/</code> 只保留为旧链接兼容跳转。后续优先少改成熟画布结构,只在必要时改模式文案、生成接入和结果/队列显示。</p>
<p><strong>2026-05-25 上游能力恢复版:</strong>用户明确要求“API 没关系,其他恢复,别削弱”。因此根域名画布恢复 <code>chatfire-AI/huobao-canvas</code> 的成熟节点和工作流结构推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置、多角度分镜、故事板、绘本和批量下载都保留;只继续替换品牌、路由和 API 接入。生成请求仍走 SKG 后端 <code>/api</code> 与登录 Cookie员工不需要个人 API Key。</p>
<p><strong>2026-05-25 媒体模型接入收口:</strong>图片和视频模型选择只暴露当前后端真实可用项:图片为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>;视频当前只接通 <code>Seedance 2.0 Fast</code>(真实模型 <code>doubao-seedance-2-0-fast-260128</code>)。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型。</p>
<p><strong>2026-05-25 媒体模型接入收口:</strong>图片和视频模型选择只暴露当前后端真实可用项:图片为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>;视频接通 <code>Seedance 2.0 Fast</code>(真实模型 <code>doubao-seedance-2-0-fast-260128</code>和按独立 key 配置的 <code>Grok Imagine Video</code>(真实模型 <code>grok-imagine-video</code>。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型;旧画布节点若保存了不可用视频模型,会在运行时模型清单加载后自动回退到当前可用项</p>
<p><strong>2026-05-26 公司沉淀版:</strong>画布项目从浏览器本地存储升级为服务端 Postgres 持久化;<code>localStorage</code> 只作为离线缓存和首次导入来源。后端同时建立用户、任务、资源索引和审计表,保留原有 <code>state.json</code> 文件作为任务详情真源,避免一次迁移动到大文件资产结构。</p>
<p><strong>2026-05-26 AI 润色中性化:</strong>画布 <code>AI 润色</code> 不再复用 SKG 广告文案接口 <code>/creative/copy</code>。后端新增 <code>POST /prompt/polish</code>,前端 <code>useChat</code>、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。当前润色链路会先清理上一次润色遗留的模板尾巴,再判断人物/无人/物体/场景/动物/未知主体;原文明确有人时才声明虚构 AI 角色,原文明确无人时才保留无人物约束,原文没写人时不主动造人但也不追加“必须无人物”的模板尾巴;当输入或参考图已经有人物时,按 AI 生成的虚拟角色继续描述,而不是把人物参考图判定为不可用。</p>
<p><strong>2026-05-26 我的工作流云端版:</strong>工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。</p>
@@ -657,7 +657,7 @@
<tr><td><code>web/canvas-app/src/stores/workflows.js</code></td><td>我的工作流 store调用 <code>GET/POST/DELETE /canvas-workflows</code> 读取、保存和删除当前登录用户自己的云端工作流模板。保存前会清理节点里的 <code>base64</code>、生成 URL、任务进度、错误、视频结果和 LLM 输出等运行态字段,只保留可复用的节点结构、连线、配置和提示词。</td></tr>
<tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer、<code>AI 润色</code><code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。工作流面板支持公共模板和我的工作流:公共模板走本地 <code>createNodes()</code>,我的工作流从云端 <code>workflow_data</code> 插回当前画布,并重新生成节点 ID、按视口中心重排、按映射重连边。Vue Flow 开启可见节点渲染,大画布不再把所有节点同时挂载到 DOM节点数超过 120 时隐藏 MiniMap减少点击后的同步重绘压力。底部推荐词来自共享短词池4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度。</td></tr>
<tr><td><code>web/canvas-app/src/config/suggestions.js</code></td><td>首页和画布共用的推荐词配置:维护 <code>QUICK_SUGGESTION_GROUPS</code>,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。</td></tr>
<tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code>画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。</td></tr>
<tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code> 和默认不可用的 <code>xai</code> / <code>Grok Imagine Video</code>,后者只有后端 <code>/health</code> 回传 <code>available=true</code> 时才进入生成下拉。画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。</td></tr>
<tr><td><code>web/canvas-app/src/hooks/useCachedMediaUrl.js</code></td><td>画布媒体本地缓存 Hook只缓存同源、登录保护下的 <code>/api/jobs/...</code><code>/api/agent-runs/...</code> 图片 / 视频 / 音频。图片节点和视频节点先用原始 URL 保证首屏可见,再后台写入浏览器 Cache Storage下次打开同一素材时返回本机 <code>blob:</code> URL减少反复从 VPS 下载。</td></tr>
<tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>;本地上传到图片节点的参考图也会先通过 <code>/creative/jobs/image</code> 写成后端资产,再把 <code>/api/jobs/...</code> URL 保存到节点,避免刷新后丢失。文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。<code>useChat</code> 已从 SKG 广告文案接口切到 <code>/prompt/polish</code>AI 润色显式使用 image/video prompt 模式LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG、产品、平台或营销语境后端会清理旧润色模板尾巴、判断人物/无人/物体/场景意图,并在输出后检查“有人却禁止人物、无人却新增人物、未写 SKG 却出现 SKG”等冲突。图生视频实际提交到后端后后端会对参考图追加 AI 虚拟角色条件说明,不要求前端判断图片里是否有人脸。</td></tr>
<tr><td><code>web/scripts/sync-canvas-root.mjs</code></td><td>构建桥接脚本:在 <code>next build</code> 静态导出完成后,把 Vite 画布产物 <code>web/canvas-app/dist</code> 覆盖到 <code>web/out</code> 根目录,使 <code>https://marketing.skg.com</code> 登录后直接进入画布;旧 <code>web/scripts/sync-canvas-dist.mjs</code> 保留但不再由生产构建调用。</td></tr>
@@ -692,7 +692,7 @@
<tbody>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回同时承载全局 <code>prompt_library</code><code>asset_library</code> 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 <code>state.json</code> / 资源库并写入索引;<code>/canvas-projects</code> 系列接口把画布项目按当前登录用户持久化,<code>/canvas-workflows</code> 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 <code>POST /creative/jobs/image</code> 把上传图片或空白底图写成一个只有 0 号关键帧的 <code>Job</code>,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;<code>POST /prompt/polish</code> 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 <code>_strip_previous_polish_boilerplate</code> 去掉旧模板尾巴,再用 <code>_classify_prompt_intent</code> 判断人物、无人、物体、场景、动物或未知主体,最后用 <code>_repair_polished_prompt</code> 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;<code>_append_reference_image_person_guard</code> 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;<code>/health</code> 返回 <code>database</code><code>image_options</code><code>image_size_options</code><code>video_options</code><code>video_size_options</code><code>video_duration_options</code><code>video_max_duration_seconds</code><code>/frames/{idx}/generate</code><code>model</code> 字段用于图片模型偏好,<code>size</code> 字段用于图片输出尺寸;<code>/storyboard/video</code> 继续使用 <code>model</code> 字段选择视频别名,并先校验画幅与时长能力边界,然后把 <code>GeneratedVideo</code> 写成 <code>queued</code> 占位并进入进程内视频队列。队列默认 <code>VIDEO_QUEUE_MAX_CONCURRENT=2</code><code>VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1</code>,同一用户连续提交不会占满全局并发;排队任务会回写 <code>queue_position</code><code>queue_size</code><code>queue_message</code>。旧 <code>AgentRun</code> 一键出片状态机、TK 复刻接口和 <code>POST /creative/copy</code> 作为明确的 SKG 营销文案接口继续保留。</td></tr>
<tr><td><code>api/db.py</code></td><td>Postgres 适配层:在 <code>DATABASE_URL</code> 存在且 <code>psycopg</code> 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD、我的工作流 CRUD以及把 <code>Job</code><code>AgentRun</code>、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled生产 <code>verify-prod-docker.sh</code> 会要求 <code>database.connected=true</code></td></tr>
<tr><td><code>video_model_options()</code></td><td>视频模型能力出口:如果 <code>seedance</code><code>kling</code><code>veo3</code><code>veo</code> 等业务别名实际都映射到同一个真实模型,会按真实模型去重,只给前端返回一个可用选项;当前生产真实模型为 <code>doubao-seedance-2-0-fast-260128</code>前端显示为 <code>Seedance 2.0 Fast</code>。后续只有在服务器真的配置了不同可用视频模型时,才应把新的模型重新暴露给画布</td></tr>
<tr><td><code>video_model_options()</code></td><td>视频模型能力出口:按当前视频网关过滤可真实路由的业务别名Doubao / Ark 网关只暴露 <code>doubao-seedance*</code> 真实模型Poe 网关才允许 Poe 的 Seedance / Kling / Veo 类模型;如果显式配置了 <code>VIDEO_API_BASE_URL</code><code>VIDEO_API_KEY</code> 为空,默认视频通道会标记不可用,不再回退通用 <code>LLM_API_KEY</code>。新增 <code>xai</code> / <code>grok-imagine-video</code> 独立走 <code>XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai</code><code>XAI_VIDEO_API_KEY</code><code>/v1/videos/generations</code><code>/v1/videos/{id}</code>创建返回 <code>request_id</code>、轮询完成返回 <code>video.url</code>;未配置 xAI key 时 <code>/health</code> 会标记不可用,前端不显示</td></tr>
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,<code>images/</code> 存 45 张参考图。</td></tr>
<tr><td><code>api/character_library/skg-characters</code></td><td>内置相似主体形象库:从桌面 5 套策划形象导入,<code>manifest.json</code> 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 <code>prompt_brief</code>。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。</td></tr>
<tr><td><code>asset_library/</code></td><td>全局素材库目录,和 <code>jobs/</code> 平级,不写入任何 job state。四类目录为 <code>subjects</code><code>products</code><code>scenes</code><code>videos</code>;每个素材自带 <code>manifest.json</code> 和图片/视频文件,<code>index.json</code> 只是启动扫描重建出来的缓存。库素材选用到 job 时必须复制文件到 <code>jobs/&lt;jobId&gt;/assets</code><code>storyboard-videos</code>,禁止直接保存 library 引用。</td></tr>
@@ -1266,7 +1266,7 @@ ProductRefStateItem {
<li>ASR优先走当前 OpenAI-compatible 音频转写入口;如果该网关没有 <code>/audio/transcriptions</code>,自动 fallback 到 <code>ASR_FALLBACK_MODEL</code>(默认 <code>gemini-2.5-flash</code>)的多模态音频识别。</li>
<li>Voice当前语音通道固定是 <code>VOICE_PROVIDER=azure_openai</code>,通过 <code>AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure</code> 的 OpenAI 协议生成 TTS后端按 <code>AZURE_TTS_PATHS</code> 依次尝试路径。第一步暂不默认调用。</li>
<li>Audio Product Brief默认是通用 SKG 放松产品卖点;当前第一步只保留配置,后续分镜/新配音阶段再使用。</li>
<li>Video Gen当前视频通道固定优先 Seedance<code>VIDEO_API_BASE_URL=https://ai.skg.com/doubao</code> 走 content JSON 异步任务,提交后写入候选片段并轮询到完成</li>
<li>Video Gen当前视频通道默认 Seedance<code>VIDEO_API_BASE_URL=https://ai.skg.com/doubao</code> 走 content JSON 异步任务。新增 <code>xai</code> / <code>Grok Imagine Video</code> 时,后端按模型分流到 <code>XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai</code><code>/v1/videos/generations</code>,使用 <code>request_id</code> 轮询 <code>/v1/videos/{id}</code>,完成后下载 <code>video.url</code> 写入候选片段</li>
<li>Compose还没做本地 ffmpeg 字幕/TTS 合成。</li>
</ul>
</div>
@@ -1310,6 +1310,33 @@ ProductRefStateItem {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-06-03 · 视频模型清单改为运行时真源</h3>
<span class="tag amber">API</span>
<span class="tag rose">UI</span>
<span class="tag blue">Docs</span>
</header>
<div class="body">
<p><strong>问题:</strong>本地接入 Grok Imagine Video 后,<code>/health</code> 仍把 Doubao 网关下的 <code>kling-omni</code><code>veo-3.1-fast</code> 作为可用视频模型返回,旧画布节点保存的 <code>Kling</code> 会继续提交到错误网关并触发 400。</p>
<p><strong>改动:</strong><code>api/main.py</code> 的视频模型清单按当前网关过滤Doubao / Ark 只暴露 <code>doubao-seedance*</code>Poe 才暴露 Poe 的 Seedance / Kling / Veo显式配置 <code>VIDEO_API_BASE_URL</code> 但未配置 <code>VIDEO_API_KEY</code> 时,默认视频通道不再借用 <code>LLM_API_KEY</code> 标记可用。视频提交入口新增可用模型校验,浏览器缓存硬塞的不可用模型会被本服务拦截。</p>
<p><strong>前端:</strong><code>web/canvas-app/src/stores/pinia/models.js</code> 在成功读取运行时模型后,以 <code>/health</code> 返回的可用视频模型替代静态视频清单;<code>VideoConfigNode.vue</code> 监听可用模型变化,旧节点若保存了不可用模型,会自动切回当前可用模型和对应清晰度。</p>
<p><strong>影响:</strong>本地只配置 <code>XAI_VIDEO_API_KEY</code> 时,画布视频下拉只显示 Grok Imagine Video同时配置有效 <code>VIDEO_API_KEY</code> 时才显示 Seedance。Kling / Veo 不会再因旧环境变量或旧缓存进入生成下拉。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-06-03 · 接入 xAI Grok Imagine Video</h3>
<span class="tag blue">API</span>
<span class="tag violet">Model</span>
<span class="tag green">Canvas</span>
</header>
<div class="body">
<p><strong>问题:</strong>SKG xAI 网关 <code>https://ai.skg.com/ezlink/xai</code> 已确认可用 <code>grok-imagine-video</code> 文生视频,但项目只把 Seedance 暴露给画布,后端也按单一视频网关处理,无法同时保留 Seedance 并新增 xAI。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增 <code>xai</code> / <code>grok-imagine-video</code> 视频模型别名、<code>XAI_VIDEO_API_BASE_URL</code> / <code>XAI_VIDEO_API_KEY</code> / <code>XAI_VIDEO_CREATE_PATH</code> / <code>XAI_VIDEO_STATUS_PATH</code> 配置,按模型分流到 <code>/v1/videos/generations</code><code>/v1/videos/{id}</code>;创建时识别 xAI 的 <code>request_id</code>,轮询完成时读取 <code>video.url</code> 并下载 MP4。视频创建、轮询和 MP4 下载统一复用 <code>ai_http_client()</code>,可走 <code>AI_HTTP_PROXY</code>MP4 下载会跟随重定向并重试,避免 <code>vidgen.x.ai</code> TLS 握手偶发失败时直接丢结果。纯文生视频不会把系统空白帧误传为参考图;图生视频会把用户上传首帧作为 <code>image</code> 传入。</p>
<p><strong>前端 / 配置:</strong><code>web/canvas-app/src/config/models.js</code> 新增默认不可用的 <code>xai</code> 模型,<code>web/canvas-app/src/stores/pinia/models.js</code> 改为接受后端 <code>/health</code> 返回的可用视频模型,不再硬编码只保留 Seedance。<code>api/.env.example</code><code>deploy/.env.local.example</code><code>deploy/.env.production.example</code> 增加 xAI 私有 key 配置位,真实 key 只填本地或服务器私有 env。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-30 · 稳定性 / 安全加固子进程超时、SSRF、并发锁、上传持久化、轮询容错</h3>

124
scripts/check-huobao-upstream.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_URL="${HUOBAO_WATCH_REPO_URL:-https://github.com/chatfire-AI/huobao-canvas}"
REF_NAME="${HUOBAO_WATCH_REF:-refs/heads/main}"
STATE_DIR="${HUOBAO_WATCH_STATE_DIR:-$ROOT_DIR/.logs/upstream-watch}"
LAST_SHA_FILE="$STATE_DIR/huobao-canvas.last-sha"
LAST_CHECK_FILE="$STATE_DIR/huobao-canvas.last-check"
LATEST_REPORT_FILE="$STATE_DIR/huobao-canvas.latest-update.md"
LOG_FILE="$STATE_DIR/huobao-canvas.watch.log"
mkdir -p "$STATE_DIR"
timestamp() {
date "+%Y-%m-%d %H:%M:%S %z"
}
log() {
printf "%s %s\n" "$(timestamp)" "$*" | tee -a "$LOG_FILE"
}
notify() {
local title="$1"
local message="$2"
if [[ "${HUOBAO_WATCH_NOTIFY:-1}" != "1" ]]; then
return 0
fi
if ! command -v osascript >/dev/null 2>&1; then
return 0
fi
/usr/bin/osascript \
-e 'on run argv' \
-e 'display notification (item 2 of argv) with title (item 1 of argv)' \
-e 'end run' \
"$title" "$message" >/dev/null 2>&1 || true
}
git_no_proxy() {
git -c http.proxy= -c https.proxy= "$@"
}
remote_line="$(git_no_proxy ls-remote "$REPO_URL" "$REF_NAME" | head -n 1 || true)"
if [[ -z "$remote_line" ]]; then
log "ERROR failed to query $REPO_URL $REF_NAME"
notify "huobao-canvas 检查失败" "无法读取 GitHub 上游,请看 $LOG_FILE"
exit 1
fi
current_sha="$(awk '{print $1}' <<<"$remote_line")"
if [[ -z "$current_sha" ]]; then
log "ERROR empty sha from $REPO_URL $REF_NAME"
notify "huobao-canvas 检查失败" "上游返回空提交号,请看 $LOG_FILE"
exit 1
fi
previous_sha=""
if [[ -f "$LAST_SHA_FILE" ]]; then
previous_sha="$(tr -d '[:space:]' < "$LAST_SHA_FILE")"
fi
printf "%s %s %s\n" "$(timestamp)" "$REPO_URL" "$current_sha" > "$LAST_CHECK_FILE"
if [[ -z "$previous_sha" ]]; then
printf "%s\n" "$current_sha" > "$LAST_SHA_FILE"
log "initialized huobao-canvas upstream watch at $current_sha"
if [[ "${HUOBAO_WATCH_NOTIFY_ON_INIT:-0}" == "1" ]]; then
notify "huobao-canvas 已开始关注" "当前 main: ${current_sha:0:7}"
fi
exit 0
fi
if [[ "$current_sha" == "$previous_sha" ]]; then
log "unchanged huobao-canvas main at ${current_sha:0:7}"
if [[ "${HUOBAO_WATCH_NOTIFY_UNCHANGED:-0}" == "1" ]]; then
notify "huobao-canvas 无更新" "当前 main 仍是 ${current_sha:0:7}"
fi
exit 0
fi
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/huobao-canvas-watch.XXXXXX")"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
repo_dir="$tmp_dir/repo"
git_no_proxy clone --depth=50 "$REPO_URL" "$repo_dir" >/dev/null 2>&1 || {
log "ERROR update detected but failed to clone $REPO_URL"
notify "huobao-canvas 有更新但拉取失败" "${previous_sha:0:7} -> ${current_sha:0:7},请看 $LOG_FILE"
exit 1
}
latest_subject="$(git -C "$repo_dir" log -1 --format=%s "$current_sha" 2>/dev/null || echo "unknown commit")"
latest_author="$(git -C "$repo_dir" log -1 --format=%an "$current_sha" 2>/dev/null || echo "unknown author")"
latest_date="$(git -C "$repo_dir" log -1 --date=format-local:"%Y-%m-%d %H:%M:%S %z" --format=%ad "$current_sha" 2>/dev/null || echo "unknown date")"
commit_list="$(git -C "$repo_dir" log --oneline --max-count=20 "$previous_sha..$current_sha" 2>/dev/null || git -C "$repo_dir" log -1 --oneline "$current_sha")"
compare_url="https://github.com/chatfire-AI/huobao-canvas/compare/$previous_sha...$current_sha"
cat > "$LATEST_REPORT_FILE" <<REPORT
# huobao-canvas upstream update
- Checked at: $(timestamp)
- Repository: $REPO_URL
- Ref: $REF_NAME
- Previous: $previous_sha
- Current: $current_sha
- Latest commit: ${current_sha:0:7} $latest_subject
- Author: $latest_author
- Commit date: $latest_date
- Compare: $compare_url
## Commits
\`\`\`
$commit_list
\`\`\`
REPORT
printf "%s\n" "$current_sha" > "$LAST_SHA_FILE"
log "UPDATED huobao-canvas ${previous_sha:0:7} -> ${current_sha:0:7}: $latest_subject"
notify "huobao-canvas 有更新" "${previous_sha:0:7} -> ${current_sha:0:7}: $latest_subject"

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LABEL="com.skg.huobao-canvas.upstream-watch"
SOURCE_PLIST="$ROOT_DIR/scripts/launchd/$LABEL.plist"
INSTALL_DIR="$HOME/Library/LaunchAgents"
INSTALL_PLIST="$INSTALL_DIR/$LABEL.plist"
LAUNCHD_DOMAIN="gui/$(id -u)"
LOG_DIR="$ROOT_DIR/.logs/upstream-watch"
if [[ ! -f "$SOURCE_PLIST" ]]; then
echo "missing launchd plist: $SOURCE_PLIST" >&2
exit 1
fi
mkdir -p "$INSTALL_DIR" "$LOG_DIR"
cp "$SOURCE_PLIST" "$INSTALL_PLIST"
plutil -lint "$INSTALL_PLIST" >/dev/null
launchctl bootout "$LAUNCHD_DOMAIN/$LABEL" >/dev/null 2>&1 || true
launchctl bootstrap "$LAUNCHD_DOMAIN" "$INSTALL_PLIST"
launchctl kickstart -k "$LAUNCHD_DOMAIN/$LABEL"
echo "huobao-canvas upstream watch installed"
echo "label: $LABEL"
echo "schedule: daily 09:30 local time"
echo "plist: $INSTALL_PLIST"
echo "state/logs: $LOG_DIR"

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.skg.huobao-canvas.upstream-watch</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>cd /Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 &amp;&amp; exec ./scripts/check-huobao-upstream.sh</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HUOBAO_WATCH_NOTIFY</key>
<string>1</string>
<key>HUOBAO_WATCH_NOTIFY_UNCHANGED</key>
<string>0</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.logs/upstream-watch/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.logs/upstream-watch/launchd.err.log</string>
<key>RunAtLoad</key>
<true/>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<key>KeepAlive</key>
<false/>
</dict>
</plist>

View File

@@ -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)

View File

@@ -127,6 +127,24 @@ export const VIDEO_MODELS = [
defaultResolution: '720p',
defaultParams: { ratio: '720x1280', duration: 10, resolution: '720p' }
},
{
label: 'Grok Imagine Video',
key: 'xai',
provider: ['chatfire'],
type: 't2v+i2v',
ratios: ['720x1280', '1280x720', '1024x1024'],
durs: [
{ label: '5 秒', key: 5 },
{ label: '8 秒', key: 8 },
{ label: '10 秒', key: 10 },
{ label: '12 秒', key: 12 },
{ label: '15 秒', key: 15 }
],
resolutions: ['480p', '720p'],
defaultResolution: '720p',
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' },
available: false
},
{
label: 'Seedance 2.0 高清',
key: 'seedance_hd',

View File

@@ -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) ============
@@ -460,13 +463,13 @@ export const useModelStore = defineStore('model', () => {
.filter(Boolean)
const videoOptions = data?.models?.video_options || []
runtimeVideoModels.value = videoOptions
.filter(item => {
const id = String(item?.id || '').toLowerCase()
const model = String(item?.model || '').toLowerCase()
return id.includes('seedance') || model.includes('seedance')
})
.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)
@@ -517,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 }
@@ -633,6 +636,7 @@ export const useModelStore = defineStore('model', () => {
allVideoModels,
runtimeImageModels,
runtimeVideoModels,
runtimeVideoModelsLoaded,
// Available models filtered by provider
availableChatModels,