Compare commits
11 Commits
22421eb117
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d82c8d22b | |||
| cfe963a2c7 | |||
| 549082ace3 | |||
| 88d598303c | |||
| 3f5dfdc465 | |||
| 3f216727bb | |||
| b1aab451ef | |||
| ff0bfaa8b2 | |||
| d038f1b2f4 | |||
| e14acee2a7 | |||
| 538bfb8f59 |
@@ -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 @@
|
||||
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:2599ae96-47e6-4592-b382-3183737f9da3 · 时间未知
|
||||
- Claude:eb894273-808b-439c-b4b9-840ad2553d0b · 时间未知
|
||||
- Codex:019e63ac-1984-7a42-8c65-ffd7ab146968 · 时间未知
|
||||
|
||||
## Claude 最近会话
|
||||
|
||||
- Session ID:2599ae96-47e6-4592-b382-3183737f9da3
|
||||
- Transcript:/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/2599ae96-47e6-4592-b382-3183737f9da3.jsonl
|
||||
- Session ID:eb894273-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-26(CST)的活跃用户和动作。
|
||||
> 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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "xAI \/ Grok Imagine Video 独立视频通道 API Key;生产只放服务器 deploy\/.env.production 的 XAI_VIDEO_API_KEY,本地开发放 api\/.env 或 deploy\/.env.local,不入库",
|
||||
"name" : "XAI_VIDEO_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.local \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "生产网页登录备用账号已停用,当前只允许飞书免登录;如需紧急恢复,需在服务器 deploy\/.env.production 显式开启 PASSWORD_AUTH_ENABLED=true。备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name" : "WEB_LOGIN",
|
||||
|
||||
@@ -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 远端记录
|
||||
|
||||
13
RULES.md
13
RULES.md
@@ -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`
|
||||
@@ -27,6 +28,9 @@
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
- Grok 创建重试与 AI 润色修复生产部署(2026-06-04):`88d5983`(AI 润色可用模型 fallback)和 `549082a`(Grok/xAI 视频创建阶段瞬时错误重试)已推送 Gitea 并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前生产备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260604064742.tgz`。生产 Docker 重建后脚本验证通过(web/api/postgres Up、`web:no_local_api_refs`、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []`、`api:health ok db connected`);补验 `https://marketing.skg.com/login/` 返回 200,`https://marketing.skg.com/api/health` 未登录返回 401。生产 API 容器内 `/health` 确认 `xai_video_configured=true`、`xai_video_model=grok-imagine-video`、`xai_video_create_path=/v1/videos/generations`、`video_create_retry_attempts=3`、`video_create_retry_backoff_seconds=2.0`,视频模型列表中 `xai / Grok Imagine Video` 仍为 `available=true`。
|
||||
- Grok 创建重试生产真实生成验收(2026-06-04):部署后在生产 API 容器内用临时飞书测试身份走真实 HTTP API 创建 job `213509450eab`,触发 Grok video `5202f39c9827`,模型 `grok-imagine-video`,xAI provider id `b59a8fa0-b294-9e0c-a4bf-a0b349876eba`;轮询到 `completed` 后 `/jobs/213509450eab/storyboard-videos/5202f39c9827.mp4` 返回 `206 video/mp4`,`content-range bytes 0-1023/2274409`,确认生产创建、轮询、下载链路真实通畅。
|
||||
- Grok Imagine Video 生产接入(2026-06-03):`3f21672` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前生产备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260603155046.tgz`。本次先在服务器 `deploy/.env.production` 补齐 `VIDEO_MODEL_XAI=grok-imagine-video`、`XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai`、`XAI_VIDEO_API_KEY`、创建/轮询路径,补配置前已生成备份 `deploy/.env.production.xai-backup-20260603155008`,真实 key 不入库。生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/api/postgres Up、`web:no_local_api_refs`、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []`、`api:health ok db connected`);生产 API 容器确认 `/health` 能力中 `xai_video_configured=true`,视频模型列表含 `seedance`、`seedance_hd`、`xai:grok-imagine-video`。生产真实 HTTP 验收已直接创建测试 job `1c3e63e5791b` 并触发 video `71fab5ea21d4`,xAI provider id `e33bcb9e-d3dc-9a7c-92c4-480b4879f438`,最终 `/jobs/1c3e63e5791b/storyboard-videos/71fab5ea21d4.mp4` 返回 `206 video/mp4`、`content-range bytes 0-1023/1125433`,确认创建、轮询、MP4 下载链路通畅。
|
||||
- Agent Cut 独立预览服务器:`2.24.28.41`(Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`,Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
|
||||
- Agent Cut 独立预览验证(2026-05-21):已在 `2.24.28.41` 的 `/opt/skg-marketing-studio` 用 `docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200,`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true` 且 `auth_configured:true`。
|
||||
- 稳定性/安全加固(2026-05-30):`3572dde`(含 `3ed3f72` fix(api) / `b56d517` fix(canvas) / `6201ee9` fix(web))已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260529181045.tgz`。本次后端:`run()` 子进程加超时(下载 `DOWNLOAD_TIMEOUT_SECONDS` 默认 600s、其余 300s,超时 kill 并标 failed)、新增 `validate_source_url()` SSRF 白名单(拒绝 `file://`/私有·环回·链路本地 IP,域名走 `SOURCE_URL_ALLOWED_HOSTS`,默认主流短视频平台)、per-job `RLock` 保护 `save_state`/`update`/`update_generated_video` 与 retry 的 check-and-set、`db.py` 改用 `psycopg_pool` 连接池且写失败 `logging.error` 暴露、只读媒体 GET 改用不创建目录的 `job_path()`、多处 `Image.open()` 改 `with` 防 fd 泄漏;新增后端依赖 `psycopg-pool`(未装自动回退)。前端:画布 VideoNode 上传改走后端 `/jobs/upload` 拿稳定 URL 并在 `cleanNodeForStorage` 剥 `blob:`、`useCachedMediaUrl` 用真实 `blob.size` 统计缓存并补 catch 竞态校验、读参考图补 credentials、删除与 Canvas 层重复的节点级视频轮询与 `api/video.js` 死代码、`request.js` timeout 改 60s+withCredentials;首页/详情页视频轮询改为容错(连续失败 10 次才停)、agent 页预览 objectURL 移入 effect、登录页 pointermove rAF 节流。飞书登录自动跳转行为按确认保留不动。本地 `python3 -m py_compile api/main.py api/db.py` 与 `cd web && pnpm build`(canvas + next)通过(本机 Docker web 镜像因 next/font 拉取 Google Fonts 受限未重建,生产服务器构建正常)。生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/api/postgres Up、`web:no_local_api_refs`、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []`、`api:health ok db connected`);生产 API 容器内复验 `psycopg_pool 3.2.4` 生效、`validate_source_url` 对 `file://`/`169.254.169.254`/`evil.com` 返回 400 而 `tiktok.com` 放行、`run()` 默认 timeout=300、`DOWNLOAD_TIMEOUT_SECONDS=600`。新增可选 env:`DOWNLOAD_TIMEOUT_SECONDS`、`SOURCE_URL_ALLOWED_HOSTS`、`DB_POOL_MAX_SIZE`。
|
||||
@@ -148,7 +152,8 @@
|
||||
- `LOCAL_ASR_BIN` / `LOCAL_ASR_MODEL` / `LOCAL_ASR_TIMEOUT_SECONDS`:本机 ASR 兜底,默认使用 `/opt/homebrew/bin/mlx_whisper` + `mlx-community/whisper-tiny`,用于当前 SKG 网关 `/audio/transcriptions` 不可用时生成真实逐句时间轴
|
||||
- `TRANSLATE_MODEL`:字幕翻译模型,默认 `gemini-2.5-flash`
|
||||
- `GPT_TEXT_MODEL`:GPT 文本 / 视觉默认模型,默认 `gpt-4o`;用于兜底修正旧 Gemini 覆盖值
|
||||
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
|
||||
- `REWRITE_MODEL`:通用改写/分镜描述主模型,当前用于 AI 润色时默认 `gpt-4o-mini`;如果主模型不可用,`/prompt/polish` 会继续尝试 `REWRITE_MODEL_FALLBACKS`
|
||||
- `REWRITE_MODEL_FALLBACKS`:AI 润色备用模型列表,逗号分隔,默认 `gpt-4o-mini,gemini-2.5-flash`;只有全部失败时才允许返回本地模板 fallback
|
||||
- `VISION_MODEL`:关键帧画面理解模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
|
||||
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;如果旧环境仍写 `gemini-*`,后端会自动改用 `REWRITE_MODEL`
|
||||
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
|
||||
@@ -158,12 +163,14 @@
|
||||
- `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。
|
||||
- `VIDEO_CREATE_RETRY_ATTEMPTS` / `VIDEO_CREATE_RETRY_BACKOFF_SECONDS`:视频创建请求的瞬时错误重试配置,默认 Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 时最多尝试 3 次,基础退避 2 秒;400/401/403/404 等参数或权限错误不重试,避免掩盖真实配置问题。
|
||||
- `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`,不入库。
|
||||
|
||||
@@ -30,7 +30,8 @@ LOCAL_ASR_MODEL=mlx-community/whisper-tiny
|
||||
LOCAL_ASR_TIMEOUT_SECONDS=180
|
||||
TRANSLATE_MODEL=gemini-2.5-flash
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o-mini
|
||||
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
|
||||
VISION_MODEL=gpt-4o
|
||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
@@ -61,6 +62,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 +99,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=
|
||||
@@ -105,6 +115,8 @@ VIDEO_STATUS_PATH=/videos/{id}
|
||||
VIDEO_CONTENT_PATH=/videos/{id}/content
|
||||
VIDEO_DURATION_FIELD=seconds
|
||||
VIDEO_POLL_TIMEOUT_SECONDS=900
|
||||
VIDEO_CREATE_RETRY_ATTEMPTS=3
|
||||
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
|
||||
|
||||
# 工作目录
|
||||
KEYFRAME_COUNT=12
|
||||
|
||||
448
api/main.py
448
api/main.py
@@ -107,6 +107,11 @@ def gpt_model_env(name: str, default: str | None = None) -> str:
|
||||
|
||||
|
||||
REWRITE_MODEL = gpt_model_env("REWRITE_MODEL")
|
||||
REWRITE_MODEL_FALLBACKS = [
|
||||
model.strip()
|
||||
for model in os.getenv("REWRITE_MODEL_FALLBACKS", "gpt-4o-mini,gemini-2.5-flash").split(",")
|
||||
if model.strip()
|
||||
]
|
||||
VISION_MODEL = gpt_model_env("VISION_MODEL")
|
||||
IMAGE_BASE_URL = os.getenv("IMAGE_BASE_URL", LLM_BASE_URL).strip()
|
||||
IMAGE_API_KEY = os.getenv("IMAGE_API_KEY", LLM_API_KEY).strip()
|
||||
@@ -350,9 +355,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 +416,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",
|
||||
@@ -418,6 +451,8 @@ VIDEO_STATUS_PATH = os.getenv("VIDEO_STATUS_PATH", DEFAULT_VIDEO_STATUS_PATH).st
|
||||
VIDEO_CONTENT_PATH = os.getenv("VIDEO_CONTENT_PATH", DEFAULT_VIDEO_CONTENT_PATH).strip() or DEFAULT_VIDEO_CONTENT_PATH
|
||||
VIDEO_DURATION_FIELD = os.getenv("VIDEO_DURATION_FIELD", "seconds").strip() or "seconds"
|
||||
VIDEO_POLL_TIMEOUT_SECONDS = max(60, int(os.getenv("VIDEO_POLL_TIMEOUT_SECONDS", "900")))
|
||||
VIDEO_CREATE_RETRY_ATTEMPTS = max(1, int(os.getenv("VIDEO_CREATE_RETRY_ATTEMPTS", "3")))
|
||||
VIDEO_CREATE_RETRY_BACKOFF_SECONDS = max(0.5, float(os.getenv("VIDEO_CREATE_RETRY_BACKOFF_SECONDS", "2")))
|
||||
FFMPEG_BIN = os.getenv("FFMPEG_BIN", "").strip()
|
||||
FFPROBE_BIN = os.getenv("FFPROBE_BIN", "").strip()
|
||||
LOCAL_FFMPEG_CANDIDATES = [
|
||||
@@ -1446,13 +1481,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 +1514,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 +1524,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 +1540,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 +5047,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 +5069,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 +5108,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 +5125,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 +5139,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 +5163,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
|
||||
@@ -5796,6 +5921,7 @@ class CreativeCopyResp(BaseModel):
|
||||
class PromptPolishReq(BaseModel):
|
||||
text: str
|
||||
system_prompt: str = ""
|
||||
model: str = ""
|
||||
mode: Literal["image", "video", "general", "chat"] = "image"
|
||||
target_language: Literal["en", "zh", "keep"] = "en"
|
||||
|
||||
@@ -6202,11 +6328,26 @@ def _prompt_polish_fallback(req: PromptPolishReq) -> PromptPolishResp:
|
||||
return PromptPolishResp(model="fallback", text=_sanitize_polished_prompt(req, intent, _basic_polished_prompt(req, intent)))
|
||||
|
||||
|
||||
def _repair_polished_prompt(req: PromptPolishReq, intent: PromptIntent, output: str, *, allow_llm: bool = False) -> str:
|
||||
def _prompt_polish_model_candidates(req: PromptPolishReq) -> list[str]:
|
||||
requested = (req.model or "").strip()
|
||||
candidates = [requested, REWRITE_MODEL, *REWRITE_MODEL_FALLBACKS]
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for model in candidates:
|
||||
model = model.strip()
|
||||
key = model.lower()
|
||||
if model and key not in seen:
|
||||
out.append(model)
|
||||
seen.add(key)
|
||||
return out
|
||||
|
||||
|
||||
def _repair_polished_prompt(req: PromptPolishReq, intent: PromptIntent, output: str, *, allow_llm: bool = False, model: str | None = None) -> str:
|
||||
out = _sanitize_polished_prompt(req, intent, output)
|
||||
issue = _polished_prompt_issue(intent, out)
|
||||
if not issue or not allow_llm or not LLM_API_KEY:
|
||||
return out
|
||||
repair_model = (model or REWRITE_MODEL).strip() or REWRITE_MODEL
|
||||
repair_prompt = (
|
||||
"Repair the rewritten generation prompt so it follows the source input exactly.\n"
|
||||
f"Issue to fix: {issue}.\n"
|
||||
@@ -6222,7 +6363,7 @@ def _repair_polished_prompt(req: PromptPolishReq, intent: PromptIntent, output:
|
||||
)
|
||||
try:
|
||||
resp = llm().chat.completions.create(
|
||||
model=REWRITE_MODEL,
|
||||
model=repair_model,
|
||||
messages=[
|
||||
{"role": "system", "content": "You repair generation prompts by removing contradictions and preserving only source intent."},
|
||||
{"role": "user", "content": repair_prompt},
|
||||
@@ -6292,23 +6433,28 @@ def polish_prompt(req: PromptPolishReq) -> PromptPolishResp:
|
||||
prompt += f"\nUser-selected polishing guidance:\n{user_system[:1000]}\n"
|
||||
prompt += f"\nSource input:\n{intent.cleaned_text[:2500]}"
|
||||
|
||||
try:
|
||||
resp = llm().chat.completions.create(
|
||||
model=REWRITE_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a neutral professional prompt editor. Preserve source intent exactly and never inject SKG or unrelated brands, products, platforms, people, or marketing context."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
temperature=0.45,
|
||||
max_tokens=900,
|
||||
)
|
||||
out = _clean_prompt_output(resp.choices[0].message.content or "")
|
||||
if not out:
|
||||
out = _prompt_polish_fallback(req).text
|
||||
return PromptPolishResp(model=REWRITE_MODEL, text=_repair_polished_prompt(req, intent, out, allow_llm=True))
|
||||
except Exception as e:
|
||||
print(f"[prompt polish fallback] {e}", flush=True)
|
||||
return _prompt_polish_fallback(req)
|
||||
model_errors: list[str] = []
|
||||
for model in _prompt_polish_model_candidates(req):
|
||||
try:
|
||||
resp = llm().chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a neutral professional prompt editor. Preserve source intent exactly and never inject SKG or unrelated brands, products, platforms, people, or marketing context."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
temperature=0.45,
|
||||
max_tokens=900,
|
||||
)
|
||||
out = _clean_prompt_output(resp.choices[0].message.content or "")
|
||||
if not out:
|
||||
raise RuntimeError("empty prompt polish response")
|
||||
return PromptPolishResp(model=model, text=_repair_polished_prompt(req, intent, out, allow_llm=True, model=model))
|
||||
except Exception as e:
|
||||
message = str(e).replace("\n", " ")[:400]
|
||||
model_errors.append(f"{model}: {message}")
|
||||
print(f"[prompt polish model fallback] model={model} error={message}", flush=True)
|
||||
print(f"[prompt polish fallback] {' | '.join(model_errors)}", flush=True)
|
||||
return _prompt_polish_fallback(req)
|
||||
|
||||
|
||||
@app.post("/translate")
|
||||
@@ -6585,6 +6731,12 @@ def health() -> dict:
|
||||
"video_base_url": video_api_base(),
|
||||
"video_configured": bool(video_api_key()),
|
||||
"video_create_paths": VIDEO_CREATE_PATHS,
|
||||
"video_create_retry_attempts": VIDEO_CREATE_RETRY_ATTEMPTS,
|
||||
"video_create_retry_backoff_seconds": VIDEO_CREATE_RETRY_BACKOFF_SECONDS,
|
||||
"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 +8984,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 +9000,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 +9049,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 +9062,28 @@ 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",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"remote protocol error",
|
||||
"ssl:",
|
||||
"_ssl.c",
|
||||
"handshake",
|
||||
"unexpected_eof",
|
||||
"eof occurred",
|
||||
"网络",
|
||||
"dns",
|
||||
)):
|
||||
return "视频生成失败:服务器连接视频模型网关异常,请稍后重试;如果连续失败,请联系管理员检查视频网关网络。"
|
||||
|
||||
if any(token.lower() in lower for token in (
|
||||
"InputImageSensitiveContentDetected.PrivacyInformation".lower(),
|
||||
"privacyinformation",
|
||||
@@ -8951,6 +9131,19 @@ def _video_public_error(raw: object) -> str:
|
||||
if any(token in lower for token in ("timeout", "timed out", "readtimeout", "connecttimeout", "超时")):
|
||||
return "视频生成失败:视频模型响应超时,可能是上游繁忙或网络不稳定。请稍后重试,或缩短时长后再生成。"
|
||||
|
||||
if any(token in lower for token in (
|
||||
"http 500",
|
||||
"http 502",
|
||||
"http 503",
|
||||
"http 504",
|
||||
"internal server error",
|
||||
"bad gateway",
|
||||
"service unavailable",
|
||||
"gateway timeout",
|
||||
"server error",
|
||||
)):
|
||||
return "视频生成失败:视频模型上游服务暂时异常,系统已自动重试但仍未成功。请稍后重新生成;如果持续出现,请联系管理员检查视频网关。"
|
||||
|
||||
if any(token in lower for token in (
|
||||
"name or service not known",
|
||||
"temporary failure in name resolution",
|
||||
@@ -8958,6 +9151,9 @@ def _video_public_error(raw: object) -> str:
|
||||
"connection refused",
|
||||
"network is unreachable",
|
||||
"connecterror",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"remote protocol error",
|
||||
"ssl:",
|
||||
"网络",
|
||||
"dns",
|
||||
@@ -8987,13 +9183,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 +9247,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 +9287,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():
|
||||
@@ -9091,6 +9332,21 @@ def submit_video_create(
|
||||
)
|
||||
|
||||
|
||||
_VIDEO_CREATE_RETRY_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
def _video_create_attempts(model: str | None) -> int:
|
||||
return VIDEO_CREATE_RETRY_ATTEMPTS if video_uses_xai(model) else 1
|
||||
|
||||
|
||||
def _video_create_retry_delay(attempt: int) -> float:
|
||||
return min(20.0, VIDEO_CREATE_RETRY_BACKOFF_SECONDS * (2 ** max(0, attempt - 1)))
|
||||
|
||||
|
||||
def _video_create_transport_error(exc: Exception) -> bool:
|
||||
return isinstance(exc, (httpx.TransportError, httpx.TimeoutException))
|
||||
|
||||
|
||||
def render_storyboard_video(
|
||||
job_id: str,
|
||||
local_id: str,
|
||||
@@ -9112,8 +9368,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,33 +9384,54 @@ 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:
|
||||
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}:
|
||||
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}:
|
||||
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}:
|
||||
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:
|
||||
create = resp
|
||||
for create_path in video_create_paths(model):
|
||||
path = video_path(create_path)
|
||||
url = f"{base}{path}"
|
||||
attempts = _video_create_attempts(model)
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
resp = submit_video_create(client, url, headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs, primary_role)
|
||||
except Exception as exc:
|
||||
create_errors.append(f"{path} attempt {attempt}/{attempts} -> {exc.__class__.__name__}: {str(exc)[:700]}")
|
||||
if attempt < attempts and _video_create_transport_error(exc):
|
||||
delay = _video_create_retry_delay(attempt)
|
||||
print(f"[video create retry] job={job_id} video={local_id} path={path} attempt={attempt}/{attempts} error={str(exc)[:300]} retry_in={delay:.1f}s", flush=True)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise
|
||||
if video_uses_ark(model) and source_ref and resp.status_code in {400, 422}:
|
||||
create_errors.append(f"{path} + reference_video -> HTTP {resp.status_code}: {resp.text[:700]}")
|
||||
resp = submit_video_create(client, url, headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs, primary_role)
|
||||
if video_uses_ark(model) and prepared_last_img and resp.status_code in {400, 422}:
|
||||
create_errors.append(f"{path} + last_frame -> HTTP {resp.status_code}: {resp.text[:700]}")
|
||||
resp = submit_video_create(client, url, headers, ref_img, payload, None, None, prepared_product_imgs, primary_role)
|
||||
if video_uses_ark(model) and prepared_product_imgs and resp.status_code in {400, 422}:
|
||||
create_errors.append(f"{path} + product_reference -> HTTP {resp.status_code}: {resp.text[:700]}")
|
||||
resp = submit_video_create(client, url, headers, ref_img, payload, None, prepared_last_img, None, primary_role)
|
||||
if resp.status_code < 400:
|
||||
create = resp
|
||||
break
|
||||
create_errors.append(f"{path} attempt {attempt}/{attempts} -> HTTP {resp.status_code}: {resp.text[:700]}")
|
||||
if resp.status_code in _VIDEO_CREATE_RETRY_STATUS_CODES and attempt < attempts:
|
||||
delay = _video_create_retry_delay(attempt)
|
||||
print(f"[video create retry] job={job_id} video={local_id} path={path} attempt={attempt}/{attempts} http={resp.status_code} retry_in={delay:.1f}s", flush=True)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if resp.status_code not in {400, 404, 405}:
|
||||
raise RuntimeError(_video_create_failure_message(create_errors))
|
||||
break
|
||||
if create is not None:
|
||||
break
|
||||
create_errors.append(f"{video_path(create_path)} -> HTTP {resp.status_code}: {resp.text[:700]}")
|
||||
if resp.status_code not in {400, 404, 405}:
|
||||
resp.raise_for_status()
|
||||
if create is None:
|
||||
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 +9448,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 +9477,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 +9563,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 +9571,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 +9590,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]] = []
|
||||
|
||||
@@ -54,7 +54,8 @@ AI_HTTP_PROXY=
|
||||
|
||||
# Text/vision/audio model names
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o-mini
|
||||
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
|
||||
VISION_MODEL=gpt-4o
|
||||
TRANSLATE_MODEL=gemini-2.5-flash
|
||||
ASR_BASE_URL=https://ai.skg.com/azure/v1
|
||||
@@ -75,13 +76,19 @@ 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
|
||||
VIDEO_CREATE_RETRY_ATTEMPTS=3
|
||||
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
|
||||
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
|
||||
|
||||
@@ -56,7 +56,8 @@ FASTER_WHISPER_DEVICE=cpu
|
||||
FASTER_WHISPER_COMPUTE_TYPE=int8
|
||||
TRANSLATE_MODEL=gemini-2.5-flash
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o-mini
|
||||
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
|
||||
VISION_MODEL=gpt-4o
|
||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
@@ -107,10 +108,16 @@ 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
|
||||
VIDEO_CREATE_RETRY_ATTEMPTS=3
|
||||
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
|
||||
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=
|
||||
|
||||
@@ -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>
|
||||
@@ -690,9 +690,9 @@
|
||||
<h3>后端核心</h3>
|
||||
<table>
|
||||
<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/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> 占位并进入进程内视频队列。Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 会按 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code> 和 <code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code> 自动退避重试,400/403 等明确错误不重试。队列默认 <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> 会标记不可用,前端不显示。创建阶段的瞬时错误重试由 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code> / <code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code> 控制,并随 <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/<jobId>/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,45 @@ 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-04 · Grok 视频创建阶段增加瞬时错误重试</h3>
|
||||
<span class="tag blue">API</span>
|
||||
<span class="tag orange">Bugfix</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>生产排查刘凌的 Grok 视频失败时,后端状态显示模型已正确传为 <code>grok-imagine-video</code>,但 xAI 创建接口在返回 <code>request_id</code> 前出现 <code>500 Internal Server Error</code> 或 <code>Connection reset by peer</code>,旧逻辑会第一次失败就把候选视频标为失败。</p>
|
||||
<p><strong>改动:</strong><code>api/main.py</code> 给 Grok/xAI 创建阶段增加 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code> 和 <code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code>,默认遇到连接重置、超时、429 或 5xx 自动退避重试 3 次;400/401/403/404 等明确参数或权限错误不重试。<code>/health</code> 暴露非敏感重试配置,错误提示把 5xx 归类为上游视频服务暂时异常。</p>
|
||||
<p><strong>影响:</strong>Grok 通道不再因一次上游瞬时 500/断连直接失败;仍然保留日志中的每次重试状态,方便后续区分网关波动、权限问题和内容审核失败。</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>
|
||||
@@ -1390,6 +1429,19 @@ ProductRefStateItem {
|
||||
<p><strong>影响:</strong>Postgres 里的 <code>canvas_projects</code> 重新成为主存储;刷新、换浏览器或本地缓存异常时,不应再把服务端项目缩小或清空。旧项目首次迁移仍可用,但迁移动作变为非破坏性。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-06-04 · AI 润色不再静默套模板</h3>
|
||||
<span class="tag amber">API</span>
|
||||
<span class="tag violet">Canvas</span>
|
||||
<span class="tag cyan">Model</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>当前网关分组对 <code>gpt-4o</code> 返回“无可用渠道”,而 <code>/prompt/polish</code> 捕获异常后直接返回本地 <code>fallback</code>,用户看到的是固定尾巴模板,不是真正的模型润色;同时前端 <code>useChat({ model: 'gpt-4o-mini' })</code> 没有把 <code>model</code> 发给后端,配置实际上未生效。</p>
|
||||
<p><strong>改动:</strong><code>PromptPolishReq</code> 新增 <code>model</code> 字段,<code>web/canvas-app/src/hooks/useApi.js</code> 会把前端选择的模型传到 <code>/prompt/polish</code>;后端按“请求模型 → <code>REWRITE_MODEL</code> → <code>REWRITE_MODEL_FALLBACKS</code>”依次尝试,当前本地默认 <code>REWRITE_MODEL=gpt-4o-mini</code>、备用 <code>gemini-2.5-flash</code>。只有全部模型失败时才返回本地模板 <code>model=fallback</code>,并在日志里记录每个失败模型。</p>
|
||||
<p><strong>影响:</strong>画布底部和文本节点的 AI 润色会优先走真实模型输出,不再把固定 “Clear main subject...” 或 “Cinematic motion...” 当作正常润色结果;如果未来网关主模型不可用,接口会自动降级到备用模型,而不是立刻套模板。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-26 · AI 润色改为意图分类和冲突校验</h3>
|
||||
|
||||
124
scripts/check-huobao-upstream.sh
Executable file
124
scripts/check-huobao-upstream.sh
Executable 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"
|
||||
29
scripts/install-huobao-upstream-watch.sh
Executable file
29
scripts/install-huobao-upstream-watch.sh
Executable 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"
|
||||
40
scripts/launchd/com.skg.huobao-canvas.upstream-watch.plist
Normal file
40
scripts/launchd/com.skg.huobao-canvas.upstream-watch.plist
Normal 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-二创验证 && 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -200,6 +200,7 @@ export const useChat = (options = {}) => {
|
||||
body: JSON.stringify({
|
||||
text: content,
|
||||
system_prompt: options.systemPrompt || '',
|
||||
model: options.model || '',
|
||||
mode,
|
||||
target_language: options.targetLanguage || (mode === 'chat' ? 'keep' : 'en')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user