Compare commits
166 Commits
backend-wo
...
pre-redesi
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d5c32630f | |||
| 7ae92ae4d9 | |||
| 642793500f | |||
| eb4347a843 | |||
| 4efb2ce456 | |||
| cc12d7c6a7 | |||
| 77d23a06b3 | |||
| 775ad79661 | |||
| a3ddb05424 | |||
| 02a9999d8c | |||
| b6fec10371 | |||
| 7bb4f3ea9f | |||
| b82dad4aa8 | |||
| 68ecc8b97b | |||
| 8458dac4bf | |||
| 5c47ea37c9 | |||
| 36da23beb2 | |||
| a48c2965d9 | |||
| d83e56169d | |||
| 8421af2af8 | |||
| f2655e1418 | |||
| def4900c1d | |||
| c805012adc | |||
| 536b4d7f59 | |||
| 1f193e95f3 | |||
| 6597db312b | |||
| dbedabaae4 | |||
| 2b842fd106 | |||
| 9c05e0bd6e | |||
| ab31a98383 | |||
| 39b9d211aa | |||
| 215987aab6 | |||
| af7622586c | |||
| e1e9bf8ca1 | |||
| fc06816483 | |||
| 45b25d01f1 | |||
| 06f3eb0504 | |||
| 54f159b6ef | |||
| f1137cc006 | |||
| d1e2b1785e | |||
| 41e71d3865 | |||
| caa7b730a6 | |||
| d03b38d75a | |||
| 0db265f086 | |||
| 97cca8d855 | |||
| 5bffd6308e | |||
| f0f2203eed | |||
| f0f567bc16 | |||
| b4f56122f2 | |||
| 3e7c1653f4 | |||
| 5b13a5cdfc | |||
| e33463e99f | |||
| ddaa795c90 | |||
| f35bfe0c5b | |||
| 4a22ca0948 | |||
| 1d0a77b46a | |||
| ae0526955f | |||
| 54eaac0de2 | |||
| 9f57cbace1 | |||
| 64fef5ae60 | |||
| 2ce2d3ec2e | |||
| 40f1f284d3 | |||
| f495e8384b | |||
| 2c0e8a058d | |||
| ab78385633 | |||
| 5bdde89809 | |||
| fc3e64d32a | |||
| 10d955c6f2 | |||
| eeb7186d7a | |||
| 659ee10efb | |||
| b9c5511128 | |||
| 5ac48749df | |||
| c43c707cde | |||
| 85d365069b | |||
| 1618ac13f1 | |||
| 7e763cf51b | |||
| fd676c71f9 | |||
| f1c710edc2 | |||
| 52b839c614 | |||
| d82175f0f3 | |||
| b3cc0aa83c | |||
| e10b1a6e1a | |||
| 83b151df04 | |||
| bc46291afd | |||
| 35fc088375 | |||
| 3d198b024b | |||
| 5f37dd98df | |||
| 7acbfd5214 | |||
| e64bf40267 | |||
| 2366662d33 | |||
| 75666f151f | |||
| c245bff4b8 | |||
| b4a7968c1b | |||
| 3756259850 | |||
| 516d99ba8c | |||
| fd794e3e30 | |||
| 00df9d01fe | |||
| 47299396dc | |||
| 39ab443389 | |||
| 15c6f4d2fc | |||
| aabddef486 | |||
| fe60d5dc99 | |||
| bdadd71bcf | |||
| a5979bb0d7 | |||
| b9bf50f851 | |||
| ce5f3b448d | |||
| 7604ed1dfe | |||
| f574ab4775 | |||
| 818d785d14 | |||
| 97016dcc0f | |||
| 883e1d4de6 | |||
| cb991e7a17 | |||
| d461c65472 | |||
| 1f6eabc819 | |||
| 7a7a3b5e19 | |||
| 4f74fa8364 | |||
| 4753d5e8bf | |||
| 45395cfa3e | |||
| a9171e225a | |||
| b9ba8113f0 | |||
| a7aa280330 | |||
| 54979bc4e2 | |||
| e03c5db3fd | |||
| 64a9673fa1 | |||
| 980d252815 | |||
| 68ab3dab96 | |||
| e6d957fcab | |||
| ce4ff74b7d | |||
| f576875af2 | |||
| b5855fd457 | |||
| 6d950ef900 | |||
| f4d456cef0 | |||
| 3462758585 | |||
| ff7bf00f6d | |||
| 5b44d35316 | |||
| 2954e58740 | |||
| 9a4268281e | |||
| e5652c463c | |||
| 2fe3db8f88 | |||
| 49c998f66c | |||
| 3b7a06272d | |||
| e6004b3bb5 | |||
| 33b91a2b3e | |||
| 73e8ffecc6 | |||
| 32620af91d | |||
| 58fe17c5e0 | |||
| 2e2998c5df | |||
| b886e02746 | |||
| 69bb692a58 | |||
| bc0b010def | |||
| adf8b2ba0a | |||
| 33c3aef669 | |||
| 87ffa6bac7 | |||
| 06bf0ee57f | |||
| 095c6f1c00 | |||
| cc4c021074 | |||
| e19e5db595 | |||
| 92f04f1a7e | |||
| 1f600ae436 | |||
| 78bd294d57 | |||
| cdffc4ba08 | |||
| 48d4002cbd | |||
| d9b51348fe | |||
| c22bee4878 | |||
| 4991526bcc | |||
| 665a0efca6 |
@@ -13,6 +13,9 @@ web/.next
|
||||
web/out
|
||||
|
||||
api/.venv
|
||||
api/.env
|
||||
api/.env.local
|
||||
api/.env.production
|
||||
api/jobs
|
||||
jobs
|
||||
data
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -16,10 +16,16 @@ __pycache__/
|
||||
.pids/
|
||||
deploy/.env.production
|
||||
deploy/.htpasswd
|
||||
secrets/
|
||||
|
||||
# api
|
||||
api/.venv/
|
||||
api/jobs/
|
||||
asset_library/*
|
||||
!asset_library/.gitkeep
|
||||
prompt_library/*
|
||||
!prompt_library/.gitkeep
|
||||
_trash/
|
||||
|
||||
# web
|
||||
web/.next/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 项目接力
|
||||
|
||||
- 生成时间:May 16, 2026 at 16:38
|
||||
- 生成时间:May 22, 2026 at 09:01
|
||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 状态:active
|
||||
@@ -8,78 +8,141 @@
|
||||
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:be53a031-9311-4ee8-b822-d4cfb4f5e78b · 时间未知
|
||||
- Codex:019e2274-9db5-71c1-85e7-2bb0519c8ba9 · 时间未知
|
||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||
- Codex:019e49d1-d114-7d52-8cf0-fbb4585f5ce6 · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
|
||||
## Claude 最近会话
|
||||
|
||||
- Session ID:be53a031-9311-4ee8-b822-d4cfb4f5e78b
|
||||
- Transcript:/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/be53a031-9311-4ee8-b822-d4cfb4f5e78b.jsonl
|
||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/api/jobs
|
||||
- 分支:HEAD
|
||||
- Session ID:a9e0449c-d9cb-4a2a-bb16-16596dfb552a
|
||||
- Transcript:/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/a9e0449c-d9cb-4a2a-bb16-16596dfb552a.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 到当前项目目录
|
||||
- 如果让你去设计。转换层 这个你会想怎么去弄
|
||||
- 就是需要生成主体元素的6视图,因为很多时候需求方都不清楚自己要什么,
|
||||
|
||||
### Claude 最近回复
|
||||
|
||||
> 加个粗紫色滚动条,覆盖节点内所有横滚浮条:
|
||||
> 好问题。我先把转换层"到底是干嘛的"想清楚,再说怎么设计——因为之前它被反复改又清空,大概率是因为没锁定它的定位。
|
||||
>
|
||||
> ## 一、它的本质不是"聊天生图",是"定主体的确认台"
|
||||
>
|
||||
> 转换层夹在「参考帧池 → 转换层 → 主体元素」中间,真正的活儿是:**拿源视频里一个验证过的主体,二创成一个全新的、我们自己的主体**,输出是右侧"统一多角度套图"。
|
||||
>
|
||||
> 所以它要解决的核心矛盾就两个:
|
||||
> 1. **意图收敛**:这张参考帧,用户是想原样复刻、换真人、卡通化,还是只借元素?产品怎么出现?
|
||||
> 2. **花钱前的闸门**:`gpt-image-2` 单视图 60s、一套 6 视图很慢很贵,不能用户随口一句就直接烧一整套。所以"出 prompt → 确认 → 才生成"这个 gate 是刚需。
|
||||
>
|
||||
> 把它当"聊天机器人"做就会很难受(每次都得多轮对话);把它当"确认台"做就顺了。
|
||||
>
|
||||
> ## 二、关键设计判断:确认的不是"一张图的 prompt",是"主体设定(bible)"
|
||||
>
|
||||
> 你们后端已经有 `pack bible` / source brief 这套——一套图靠同一份 bible 保证多角度是**同一个主体**。
|
||||
>
|
||||
> 那 UI 上闸门就应该让用户**确认这份主体设定**,而不是确认某一张图。一旦确认:
|
||||
> - **数量 = 从这份锁定设定里渲染几个角度**
|
||||
> - 多角度统一 = 自动成立(因为大家共享同一 bible)
|
||||
>
|
||||
> 这一条想通了,"多角度风格统一"就不是额外功能,而是数据结构的自然结果。
|
||||
>
|
||||
> ## 三、流程:对话是"可选精修",不是"强制多轮"
|
||||
>
|
||||
> 你之前给 Codex 的描述(读图→问→答→弹 prompt→确认→生成)方向是对的,我只改一点:**别强制聊天**。常见情况下用户知道自己要什么,应该 3 下点完;需要讨论时聊天才出现。
|
||||
>
|
||||
> ```
|
||||
> ┌─ 转换层 ───────────────────┐
|
||||
> │ [参考帧缩略图] [+换/加帧] │ 顶部:当前参考
|
||||
> │ AI读图:白色筋膜枪·握把有手 │ 一行机读,不啰嗦
|
||||
> │ │
|
||||
> │ 想怎…
|
||||
|
||||
> 滚动条加粗到 14px + 紫色加亮:
|
||||
> 明白,这一句把设计的重心整个挪了。需求方不清楚自己要什么 → 那转换层就**不能问"你想怎么改"**(空白框对说不出需求的人是负担),它得**先替他想好,给他东西去反应**。
|
||||
>
|
||||
> | | 浮条 1 (InputNode) | 浮条 2 (VisualLab) |
|
||||
> |---|---|---|
|
||||
> | 可视宽 | 1255 | 620 |
|
||||
> | 内容总宽 | 2498 | 988 |
|
||||
> | 需要横滚 | ✅ | ✅ |
|
||||
> 这其实就是你那套讲解稿里的"快速到 60 分,不从零起步"——机器先给一个站在验证过爆款上的初稿,人在 60 分上挑、上改,而不是对着空白发愁。**同一个原则,直接套到转换层。**
|
||||
>
|
||||
> CSS 改动:所有 `.react-flow__node .overflow-x-auto` 的 webkit scrollbar 高度 14px、轨道半透明白边框、拖动滑块紫色(hover 加深、active 粉紫),最小宽度 48px 保证拖手够大。Firefox 自动用 native scrollbar-color。
|
||||
> ## 修正后的定位:Agent 主动提案,需求方只做"选/推"
|
||||
>
|
||||
> 去刷新看。如果还不够明显(被画布 zoom 缩太小),可以再加大到 18-20px,告诉我。
|
||||
> 我上一轮说的"别强制聊天、给个 chip + 自由描述框"——对**知道自己要什么**的人对,对**说不清的人是错的**。删掉。改成:
|
||||
>
|
||||
> ```
|
||||
> ┌─ 转换层 ────────────────────┐
|
||||
> │ [参考帧] AI读图:白色筋膜枪·手持 │
|
||||
> │ │
|
||||
> │ 我建议这 3 个方向(选一个): │ ← agent 主动出,不问
|
||||
> │ ┌─────────────────────────┐ │
|
||||
> │ │ A 原样复刻 · 稳 缩略│ │
|
||||
> │ │ B 换暖木纹高端款 · 提质感 缩略│ │ ← 每个都已是可生成的成品设定
|
||||
> │ │ C 卡通吉祥物版 · 抓眼球 缩略│ │
|
||||
> │ └─────────────────────────┘ │
|
||||
> │ [就用 A 生成 6 视图 →] │ ← 默认动作,零思考一键走
|
||||
> │ 不对?跟我说一句…(可选) │ ← 想拨一下才打字
|
||||
> └───────────────────────────────┘
|
||||
> ↓
|
||||
> 右侧主体元素:6 视图 queued → 逐视角回填
|
||||
> ```
|
||||
>
|
||||
> 核心三点:
|
||||
> - **进帧即出提案**。参考帧一进来,agent 自动读图 + 直接抛 2~3 个**已经成型、点了就能生成**的方向(每个背后都是一份完整主体设定 bible),而不是一个等用户填的空框。
|
||||
> - **最小动作 = 一次点击认可**。说不清没关系,默认有个推荐项,点"就用这个"直接出 6 视图。想微调才说话("再高端点…
|
||||
|
||||
## Codex 最近会话
|
||||
|
||||
- Session ID:019e2274-9db5-71c1-85e7-2bb0519c8ba9
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/14/rollout-2026-05-14T01-48-48-019e2274-9db5-71c1-85e7-2bb0519c8ba9.jsonl
|
||||
- Session ID:019e49d1-d114-7d52-8cf0-fbb4585f5ce6
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T17-15-47-019e49d1-d114-7d52-8cf0-fbb4585f5ce6.jsonl
|
||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 分支:main
|
||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||
|
||||
### 最近用户要求
|
||||
|
||||
- <image name=[Image #1]> </image> [Image #1] logo 和那个营销内容工作台 你把营销内容工作台和logo水平对齐
|
||||
- logo怎么没有在服务器上显现出来呢奇怪
|
||||
- 账号密码创建了么
|
||||
- 分镜这个板块,我每次点击视频 都变成了选择 其实我就是想打开看下而已 你这个逻辑不太对 你看下
|
||||
- 为什么要选择呢 你给我点击下载的按钮不就好了
|
||||
- 而且每个生成的视频当然是自动保存呀 还需要我点击保存干啥
|
||||
|
||||
### Codex 最近回复
|
||||
|
||||
> 原因找到了:不是 logo 文件没上去,而是生产 Nginx 把 `/skg-logo-black.svg` 当成受保护资源了,未登录访问会 `302` 到 `/login/`,所以登录页里的图片加载不到。
|
||||
>
|
||||
> 已修:
|
||||
> - `deploy/nginx.conf` 加了 `/skg-logo-black.svg` 公开静态资源规则
|
||||
> - 同步更新了 `RULES.md` 和 `docs/source-analysis.html`
|
||||
> - 已重新构建并重启生产 web 容器
|
||||
>
|
||||
> 验证结果:
|
||||
> - `https://marketing.skg.com/skg-logo-black.svg` 现在是 `200 image/svg+xml`
|
||||
> - `https://marketing.skg.com/login/` 是 `200 OK`
|
||||
> - 我截了线上图,logo 已经显示,和“营销内容工作台”也水平对齐了
|
||||
>
|
||||
> 你浏览器那边如果还看不到,强刷一下页面缓存即可。
|
||||
> 生产部署正在跑第二轮,远端备份和同步已经完成,Web 镜像在构建;我等脚本验证结束。
|
||||
|
||||
> 创建好了,生产环境现在可用:
|
||||
> 对,这个点你说得对。生成的视频本来就会写进当前 job 的 `generated_videos`,完成后自动回填 mp4,不应该让用户理解成还要点“保存视频”。
|
||||
>
|
||||
> 我已经改完并上线:
|
||||
>
|
||||
> - 分镜视频候选轨现在显示 `自动保存`
|
||||
> - 视频候选普通点击只负责打开预览
|
||||
> - 右上角提供显式 `下载` 按钮
|
||||
> - “保存全部 / 保存本条规划”改成了“同步规划 / 同步本条规划”,只表达同步三字段文案规划,不再像保存视频
|
||||
> - 源码解析页也同步更新了这条交互语义
|
||||
>
|
||||
> 验证和发布:
|
||||
>
|
||||
> - `web/npm run build` 通过
|
||||
> - 已推送 Gitea:
|
||||
> - `4efb2ce fix: make storyboard video clicks previewable`
|
||||
> - `eb4347a fix: clarify storyboard videos auto-save`
|
||||
> - 已执行 `./scripts/deploy-prod-safe.sh` 上线,生产验证通过:`/login/ 200`、`/api/health 401`、容器内 `api:health ok`
|
||||
>
|
||||
> 当前 `git status -sb`:
|
||||
>
|
||||
> ```text
|
||||
> 登录地址:https://marketing.skg.com/login/
|
||||
> 账号:skg
|
||||
> 密码:c413cdc5bbbf2ca042
|
||||
> ## main...origin/main
|
||||
> M .memory/worklog.json
|
||||
> ```
|
||||
>
|
||||
> 密码备份位置按项目规则放在服务器:`/root/skg-marketing-studio-login.txt`。前面已验证登录接口返回成功。
|
||||
> 只剩自动日志 `.memory/worklog.json`,业务代码和文档都已提交并推送。
|
||||
>
|
||||
> 补充:`npm run lint` 现在跑不了,因为项目里没有可执行的 `eslint`;`tsc --noEmit` 仍有一批既有的 dashboard/ui 旧类型错误,不是这次改动引入的。
|
||||
>
|
||||
> <oai-mem-citation>
|
||||
> <citation_entries>
|
||||
> MEMORY.md:214-286|note=[used SKG worktable context and docs update expectation]
|
||||
> </citation_entries>
|
||||
> <rollout_ids>
|
||||
> 019e2fef-97a7-7523-a1ff-051a8c6…
|
||||
|
||||
## Cursor 最近会话
|
||||
|
||||
@@ -87,9 +150,9 @@
|
||||
|
||||
## 当前仓库状态
|
||||
|
||||
- 当前分支:HEAD
|
||||
- 未提交变更:2 项
|
||||
- 最近提交:auto-save 2026-05-16 10:20 (~1)
|
||||
- 当前分支:main
|
||||
- 未提交变更:1 项
|
||||
- 最近提交:fix: clarify storyboard videos auto-save
|
||||
- 变更文件:
|
||||
- M .memory/worklog.json
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SKG TK 二创验证 — 当前状态(2026-05-13)
|
||||
# SKG TK 二创验证 — 当前状态(2026-05-18)
|
||||
|
||||
## 一句话
|
||||
SKG AI 素材生产管线第二条思路:TK 链接/上传 → 拆轨 → 抽关键帧(5 张+手动加)→ Vision 识别 → 改写文案 → 生图 → 生视频 → 合成。**MVP 通到生图,剩余 3 个节点占位**。
|
||||
SKG 信息流广告快速复刻工作台:TK 链接/上传 → 下载源视频 → 并行音频解析与 12 张动作/节奏参考帧 → 相似主体 / 产品素材池 → 分镜口播改写 → 首尾帧审核 → 视频候选待开放。当前主流程不直接批量提交视频模型。
|
||||
|
||||
## 路径 / 端口
|
||||
- 路径:`~/Projects/business/20260512-20260512-skg-tk-二创验证/`
|
||||
@@ -15,51 +15,62 @@ key 写在 `api/.env` 的 `LLM_API_KEY`
|
||||
|
||||
| 端点 / 字段 | 状态 | 用途 |
|
||||
|---|---|---|
|
||||
| `/v1/chat/completions` text-only | ✅ 通 | translate / rewrite |
|
||||
| `/v1/chat/completions` + image_url | ✅ **通**(之前误判为不通,是 dog.jpg 那张图损坏) | vision 识别图片(gemini-2.5-flash 推荐) |
|
||||
| `/v1/chat/completions` + input_audio | ❌ 不通 | ASR 不能走这条 |
|
||||
| `/v1/audio/transcriptions` (whisper) | ❌ 404 | 整个 audio 端点都没暴露 |
|
||||
| `/v1/audio/speech` (tts) | ❌ 404 | |
|
||||
| `/v1/images/generations` (text→image) | ✅ 通 | 生图(gemini-3-pro-image-preview = nano-banana-pro) |
|
||||
| `/v1/images/generations` + image 参数 | ✅ **通**(image-to-image) | 实测能传 reference image,关键的发现 |
|
||||
| `/v1/images/edits` | ❌ 404 | |
|
||||
| `/v1/videos/*` (sora-2) | ❌ 404 | 视频生成需要 IT 开通或外部 key |
|
||||
| `/v1/files` | ❌ 403 "必须指定渠道" | |
|
||||
| 远端 ASR | `ASR_MODEL=whisper-1` | 第一优先级音频转写;失败后进本机 ASR。 |
|
||||
| 本机 ASR | `LOCAL_ASR_MODEL=mlx-community/whisper-tiny` | 二级兜底,优先产出真实逐句时间轴。 |
|
||||
| ASR 兜底 / 音频分析 | `ASR_FALLBACK_MODEL=gemini-2.5-flash` | 远端和本机都失败后才做多模态 ASR;音频画像会读取 `audio.wav` + 转写时间轴,失败则本地估算。后端会拒绝假字幕、重复文本和覆盖率过低结果。 |
|
||||
| 字幕翻译 | `TRANSLATE_MODEL=gemini-2.5-flash` | 按 ASR 段落补中文;失败时保留英文时间轴,中文可为空。 |
|
||||
| 画面理解 / brief | `VISION_MODEL=gpt-4o` | 关键帧 Vision 和相似主体非身份化 brief 已切 GPT;旧环境若写 `gemini-*` 会自动归一化到 `GPT_TEXT_MODEL`。 |
|
||||
| 通用改写 / 分镜描述 | `REWRITE_MODEL=gpt-4o` | 已切 GPT;旧 Gemini 覆盖值会自动归一化。 |
|
||||
| 新口播改写 | `AUDIO_REWRITE_MODEL=gpt-4o` | 默认跟随 `REWRITE_MODEL`;失败后依次尝试 `ASR_FALLBACK_MODEL`、`TRANSLATE_MODEL`,再用本地模板兜底。 |
|
||||
| 产品视角识别 | `PRODUCT_VIEW_MODEL=gpt-image-2` | 多图批量识别;失败后单图重试,再写本地默认视角和风险备注。 |
|
||||
| 所有生图 / 修图 | `gpt-image-2` | 服务端硬锁,无其他图片模型 fallback;覆盖关键帧生图、水印清理、元素提取、主体资产包、产品补角度、首尾帧。 |
|
||||
| 配音 | `VOICE_PROVIDER=azure_openai` + `AZURE_TTS_MODEL=gpt-4o-mini-tts` | 语音固定 Azure OpenAI TTS。后端会按 `AZURE_TTS_PATHS` 依次尝试路径,便于区分路径错误和整条语音服务不可用。 |
|
||||
| 视频 | `VIDEO_MODEL=seedance`,别名支持 `kling-omni`、`veo-3.1-fast` | 当前主流程暂停直接提交;真实 ID 由 `VIDEO_MODEL_SEEDANCE` / `VIDEO_MODEL_KLING` / `VIDEO_MODEL_VEO3` 配置,入口按 `VIDEO_CREATE_PATHS`。 |
|
||||
|
||||
**网关后端 = one-hub 多渠道代理**。当前 key 分组叫「纯OpenAI+AWSClaude+Gemini官方」,缺 audio 渠道(`gpt-4o-audio-preview` 503 "无可用渠道")和 video 渠道。
|
||||
|
||||
## 模型选型(已写入 api/.env)
|
||||
## 模型选型(运行时默认 / 归一化后)
|
||||
```
|
||||
ASR_MODEL=whisper-1 # ⚠️ 端点 404,ASR 还没真跑通
|
||||
TRANSLATE_MODEL=gemini-2.5-flash # ✅ text 已通
|
||||
REWRITE_MODEL=gemini-2.5-pro # 占位
|
||||
VISION_MODEL=gemini-2.5-flash # ✅ 识别已通
|
||||
IMAGE_MODEL=gemini-3-pro-image-preview # ✅ nano-banana-pro,i2i 已通
|
||||
ASR_MODEL=whisper-1
|
||||
LOCAL_ASR_MODEL=mlx-community/whisper-tiny
|
||||
ASR_FALLBACK_MODEL=gemini-2.5-flash
|
||||
TRANSLATE_MODEL=gemini-2.5-flash
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
VISION_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
AUDIO_REWRITE_MODEL=gpt-4o
|
||||
IMAGE_MODEL=gpt-image-2
|
||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||
VOICE_PROVIDER=azure_openai
|
||||
AZURE_TTS_MODEL=gpt-4o-mini-tts
|
||||
VIDEO_MODEL=seedance
|
||||
VIDEO_MODEL_KLING=kling-omni
|
||||
VIDEO_MODEL_VEO3=veo-3.1-fast
|
||||
```
|
||||
|
||||
## Pipeline 状态(8 节点合并版)
|
||||
原 10 节点已合并:input + download + split 合一;translate 合到 transcript;videogen 和 compose 占位。
|
||||
## Pipeline 状态(9 步工作台版)
|
||||
当前主入口是信息流复刻工作表,不再是旧 ReactFlow 八节点主画布。
|
||||
|
||||
| 步 | 节点 | 状态 | 备注 |
|
||||
|---|---|---|---|
|
||||
| 1 | **输入·Input**(合并下载+拆分) | ✅ | yt-dlp 真下 + ffmpeg 拆 wav |
|
||||
| 2 | **关键帧·Keyframes** | ✅ | D 启发式:候选 30 张 → pHash 去重 + Laplacian variance 评分 + 时序分桶 → 5 张;手动加帧 OK |
|
||||
| 3 | **转录·ASR** | ❌ 阻塞 | SKG 网关 audio 不通;待 IT 开 audio 渠道 / 外部 key |
|
||||
| 4 | **翻译·Translate** | ❌ 阻塞 | 依赖 ASR |
|
||||
| 5 | **改写·Rewrite** | ⏳ 占位 | 等用户给产品信息模板 |
|
||||
| 6 | **生图·Image Gen** | ✅ **刚做完** | nano-banana-pro i2i + 正负 prompt |
|
||||
| 7 | **生视频·Video Gen** | ⏳ 占位 | sora-2 端点不通 |
|
||||
| 8 | **合成·Compose** | ⏳ 占位 | 本地 ffmpeg + 字幕 + TTS |
|
||||
| 1 | **素材输入** | ✅ | TK 链接 / 上传视频;失败素材可重新下载。 |
|
||||
| 2 | **源视频下载** | ✅ | yt-dlp + cookies 配置;上传视频直接进入 downloaded。 |
|
||||
| 3 | **音频文案** | ✅ | 拆 `audio.wav`,ASR、翻译、讲话人 / 节奏 / 背景音画像。 |
|
||||
| 4 | **抽帧参考** | ✅ | 下载完成后自动抽 12 张动作/节奏参考帧;支持当前播放点手动补帧。 |
|
||||
| 5 | **相似主体** | ✅ | GPT 视觉 brief + `gpt-image-2` 文字生图,生成类似但不复刻的人物/透明骨架主体。 |
|
||||
| 6 | **产品素材池** | ✅ | 不限量上传;`gpt-image-2` 识别视角 / 用途 / 风险,缺角度可补图。 |
|
||||
| 7 | **分镜文案** | ✅ | 按逐句时间轴生成行,可单段或整片 GPT 改写;保存后写入 storyboard action。 |
|
||||
| 8 | **画面首尾帧** | ✅ | 用相似主体视图 + 产品素材池生成首帧/尾帧,审核后保存规划。 |
|
||||
| 9 | **视频候选** | ⏸️ | 历史候选可看;主流程当前暂停直接提交视频模型。 |
|
||||
|
||||
## UI 架构(重要)
|
||||
- **左侧 sidebar**(108px 极窄):8 个 stage tile 竖排 + DAG 路径分叉表达
|
||||
- **主区 ReactFlow**:8 节点 DAG(input → keyframe/asr → ... → compose)
|
||||
- **点 sidebar tile**:从左滑出 drawer panel(粉/紫/橙 Kanban 风格)
|
||||
- **关键帧 lightbox**:**embedded 嵌入到 keyframe drawer**(不全屏)—— `<FrameLightbox embedded ... />`,drawer 宽度有 expandedFrame 时 760,无时 400
|
||||
- **Input 节点上方**:多视频缩略图浮条 + 「+」加新视频
|
||||
- **关键帧节点上方**:5+ 张缩略图按视频原比例(aspect-ratio: width/height)
|
||||
- **缩略图 hover**:弹大图静态(关键帧是垫图素材,不放视频)
|
||||
- **缩略图点击**:打开 keyframe drawer 内的 lightbox(左大图 + 右识别面板)
|
||||
- 主入口:`web/components/ad-recreation-board.tsx`,左侧素材输入列 + 右侧信息流复刻工作表。
|
||||
- 工作流条:01 素材输入 → 02 源视频下载 → 03 音频文案 → 04 抽帧参考 → 05 相似主体 → 06 产品素材池 → 07 分镜文案 → 08 画面首尾帧 → 09 视频候选。
|
||||
- 源视频工作区:左侧 9:16 原视频播放器,内置当前点抽帧;右侧音频波形 + 逐句时间轴 + 参考帧池。
|
||||
- 相似主体:模板库 / 内置形象 / 源视频相似方向;生成结果统一用媒体素材卡,支持 hover 放大、删除、单张重生。
|
||||
- 分镜工作台:产品素材池、逐句口播、画面规划、首尾帧和历史视频候选在同一纵向工作表里处理。
|
||||
- 旧 ReactFlow 节点、旧 lightbox、旧 storyboard workbench 底层保留,但当前不作为主入口。
|
||||
|
||||
## 数据模型(关键 typescript / pydantic)
|
||||
```typescript
|
||||
@@ -80,67 +91,55 @@ Job { frames: KeyFrame[] ... }
|
||||
**前端取帧必须用 `frames.find(x => x.index === activeIndex)` 不能用数组下标**(之前的 bug)。
|
||||
|
||||
## 关键文件
|
||||
- `web/app/page.tsx` — 多 job state 管理(jobs[] + activeJobId),8 节点 LAYOUT
|
||||
- `web/components/dashboard.tsx` — sidebar + drawer + 9 个 Kanban section(input/keyframe/asr/translate/rewrite/imagegen/videogen/compose),含 `ImageGenCard` 子组件
|
||||
- `web/components/lightbox.tsx` — `FrameLightbox` 支持 `embedded` prop
|
||||
- `web/app/page.tsx` — 多 job state 管理(jobs[] + activeJobId),开始后并行触发音频解析和 12 张视觉抽帧
|
||||
- `web/components/ad-recreation-board.tsx` — 当前主工作台:素材输入、音频结果、参考帧池、相似主体、产品素材池、分镜规划和首尾帧
|
||||
- `web/components/media-asset-tile.tsx` — 图片 / 视频 / 抽帧 / 产品图 / 主体图 / 首尾帧 / 视频候选统一媒体卡
|
||||
- `web/components/dashboard.tsx` — 旧 ReactFlow / Kanban 面板,底层保留但当前不作为主入口
|
||||
- `web/components/lightbox.tsx` — 旧深度素材面板,底层保留
|
||||
- `web/components/video-lightbox.tsx` — Input 节点点视频缩略图弹的播放器
|
||||
- `web/components/nodes/index.tsx` — ReactFlow 8 节点定义
|
||||
- `web/lib/api.ts` — API client
|
||||
- `api/main.py` — FastAPI 所有端点,KeyFrame/GeneratedImage 模型
|
||||
- `api/main.py` — FastAPI 所有端点,Job/KeyFrame/AudioScript/ProductRef/SubjectAsset/SceneAsset/GeneratedVideo 模型
|
||||
|
||||
## 已通的 API 端点
|
||||
```
|
||||
POST /jobs 创建 job(链接)
|
||||
POST /jobs/{id}/download/retry TK 链接下载失败后重新下载
|
||||
POST /jobs/upload 上传视频
|
||||
GET /jobs/{id} job 状态
|
||||
POST /jobs/{id}/analyze?frames=5 拆轨+抽帧+ASR 自动一气呵成
|
||||
POST /jobs/{id}/transcribe 音频提取 + ASR + 翻译 + 讲话人/节奏/背景音分析
|
||||
POST /jobs/{id}/analyze?frames=12 动作/节奏参考帧抽取
|
||||
POST /jobs/{id}/frames?t=<sec> 手动按时间戳加帧
|
||||
POST /jobs/{id}/frames/{idx}/describe ✅ Vision 识别(3 次重试 + reasoning_content 兜底)
|
||||
POST /jobs/{id}/frames/{idx}/generate ✅ 生图(i2i / text-only, 含 negative_prompt)
|
||||
GET /jobs/{id}/frames/{idx}/gen/{gen_id}.jpg 生成图二进制
|
||||
POST /jobs/{id}/frames/{idx}/gen/{gen_id}/select 选用某 gen 给下游
|
||||
POST /jobs/{id}/assets/product-views/analyze 产品视角 / 用途 / 风险识别
|
||||
POST /jobs/{id}/assets/product-angle 缺产品角度补图
|
||||
POST /jobs/{id}/script/rewrite 单段 / 整片新口播改写
|
||||
POST /jobs/{id}/frames/{idx}/scene-asset 首帧 / 尾帧 / 场景资产生成
|
||||
GET /jobs/{id}/video.mp4 原视频
|
||||
GET /jobs/{id}/audio.wav 原音频 wav
|
||||
GET /jobs/{id}/frames/{idx}.jpg 关键帧 jpg
|
||||
GET /health
|
||||
```
|
||||
|
||||
## 已知坑 / 不要再踩
|
||||
1. **关键帧 index 不连续**:手动加帧后 frames 数组按 timestamp 排序,index 是稳定 ID。lightbox 必须用 `frames.find(x => x.index === activeIndex)`,**不要**用 `frames[activeIndex]`。
|
||||
2. **SKG 网关 vision 之前测试结果错误**:用 `dog.jpg` 那张 wikipedia 200px 缩略图损坏 / metadata 异常,导致一直以为 image input 不通。用标准 PNG / 真实 jpeg 测就通了。
|
||||
3. **Gemini 2.5 Flash 默认带 thinking**,`content` 字段经常为空(token 都给了 reasoning),要从 `reasoning_content` 正则挖 JSON 兜底。
|
||||
4. **缩略图 aspect-ratio**:必须用 `aspectRatio: ${job.width}/${job.height}` 自适应,不要强制 `aspect-video` 16:9(竖屏视频会被裁切)。
|
||||
5. **ReactFlow `type="input"` / `"output"` 是 reserved**:自带白底默认样式,要 CSS 覆盖 `.react-flow .react-flow__node-input { background: transparent !important; ... }`。
|
||||
6. **ReactFlow 12 colorMode 独立于 next-themes**:必须 `<ReactFlow colorMode={resolvedTheme}>` 联动,否则节点白底。
|
||||
7. **FastAPI BackgroundTasks 用法**:`bg.add_task(func, arg)` 不能传 coroutine。
|
||||
8. **ffmpeg 8 mjpeg encoder 拒绝 yuv420p**:抽帧必须加 `-pix_fmt yuvj420p`,且 `-vsync` 改 `-fps_mode`。
|
||||
9. **抽帧速度**:场景切换检测(`select='gt(scene,0.4)'`)超慢(71s 视频要 30s+),换均匀采样 fast seek(5 张 < 3 秒)。
|
||||
## 当前约束 / 不要踩
|
||||
1. 图片 / 视频 / 抽帧 / 产品图 / 生成图 / 首尾帧 / 视频候选缩略图默认复用 `web/components/media-asset-tile.tsx`。
|
||||
2. 所有生图入口服务端只允许 `gpt-image-2`,不要重新加 Gemini 图片模型或其他 fallback。
|
||||
3. 画面理解和文案改写默认归 GPT:`VISION_MODEL`、`REWRITE_MODEL`、`AUDIO_REWRITE_MODEL` 会拦截旧 `gemini-*` 覆盖值。
|
||||
4. Gemini 仍保留在 ASR fallback / 音频分析 / 翻译链路,不要误删。
|
||||
5. 语音只走 Azure OpenAI TTS;不要新增或依赖其他配音通道配置。
|
||||
6. TikTok 受限下载遇到 `Log in for access` 不是后端没接到任务;需要 `YTDLP_COOKIES_FILE` 或 `YTDLP_COOKIES_FROM_BROWSER`,配置后可点“重新下载”。
|
||||
7. 当前主流程不直接批量提交视频;先走“分镜规划 → 首尾帧 → 人工审核”。
|
||||
8. 后端长任务不要用 `--reload`。
|
||||
9. 关键帧 `index` 是稳定 ID,不等于数组下标;前端取帧用 `frames.find(x => x.index === idx)`。
|
||||
|
||||
## 待办(按优先级)
|
||||
1. **ASR 阻塞**:找 SKG IT 开 audio 渠道,或给一个外部 ASR key(Deepgram / 讯飞 / OpenAI 直连)
|
||||
2. **生图测试反馈**:刚做完,等用户在浏览器试 → 调 negative prompt / 模型选型
|
||||
3. **区域化修图(inpainting)**:用户讨论了,方案 A 纯 prompt / B 矩形框 / C 画笔 mask / D SAM;暂时搁置
|
||||
4. **改写 Rewrite**:等用户给产品信息卡模板
|
||||
5. **视频生成**:sora-2 走 SKG 端点不通;考虑外部 key (Runway/Kling/Veo3)
|
||||
6. **合成 Compose**:全本地 ffmpeg + 字幕 + TTS
|
||||
|
||||
## 操作流(开发会话)
|
||||
```bash
|
||||
# 1. 启动后端(如未跑)
|
||||
cd ~/Projects/business/20260512-20260512-skg-tk-二创验证/api
|
||||
source .venv/bin/activate
|
||||
uvicorn main:app --port 4291 --reload
|
||||
|
||||
# 2. 启动前端(如未跑)
|
||||
cd ../web
|
||||
pnpm dev
|
||||
|
||||
# 3. 浏览器
|
||||
open http://localhost:4290/?job=c6767f3a166b
|
||||
```
|
||||
|
||||
## 用户偏好提醒(feedback memory)
|
||||
- feedback_image-gen-model:生图统一用 nano-banana-pro ✅
|
||||
- feedback_keep-scope-small:小需求小做
|
||||
- feedback_flow-dont-stop:连续执行到交付,真分叉才问
|
||||
- feedback_demand-before-infra:基建前先反问谁/痛点/频率
|
||||
- feedback_no-guessing-ports:操作前先核实
|
||||
## 最近变更
|
||||
- 2026-05-18:前端模型链路弹窗、`.project.json`、`api/README.md` 和本状态文档已按真实后端链路重写:音频三级 ASR、翻译失败行为、音频画像兜底、产品识别重试、相似主体 GPT brief + gpt-image-2 文字生图、脚本改写本地模板兜底、视频主入口暂停。
|
||||
- 2026-05-18:清理个人语音通道残留,`/health`、前端类型、环境模板和文档不再暴露相关字段或配置。
|
||||
- 2026-05-18:`VISION_MODEL`、`REWRITE_MODEL`、`AUDIO_REWRITE_MODEL` 切到 GPT 默认模型 `gpt-4o`,并加旧 Gemini 环境变量归一化保护。
|
||||
- 2026-05-18:语音通道固定 Azure OpenAI TTS,并按 `AZURE_TTS_PATHS` 尝试语音路径。
|
||||
- 2026-05-18:TikTok 受限链接支持 cookies 配置和失败素材“重新下载”。
|
||||
- 2026-05-18:媒体素材交互统一收口到 `MediaAssetTile`。
|
||||
- 2026-05-18:产品图视角识别和产品缺角度补图收敛到 `gpt-image-2`。
|
||||
|
||||
6072
.memory/worklog.json
6072
.memory/worklog.json
File diff suppressed because it is too large
Load Diff
133
.project.json
133
.project.json
@@ -1,96 +1,101 @@
|
||||
{
|
||||
"company": "SKG",
|
||||
"created": "2026-05-12",
|
||||
"credentials": [
|
||||
"company" : "SKG",
|
||||
"created" : "2026-05-12",
|
||||
"credentials" : [
|
||||
{
|
||||
"description": "SKG AI 网关 API Key,生产只放服务器 deploy/.env.production 的 LLM_API_KEY,本地开发放 api/.env,不入库",
|
||||
"name": "LLM_API_KEY",
|
||||
"storage": "api/.env / deploy/.env.production",
|
||||
"type": "api_key"
|
||||
"description" : "SKG AI 网关 API Key,生产只放服务器 deploy\/.env.production 的 LLM_API_KEY,本地开发放 api\/.env,不入库",
|
||||
"name" : "LLM_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description": "MiniMax T2A 配音 API Key,本地开发只放 api/.env 的 MINIMAX_API_KEY,不入库",
|
||||
"name": "MINIMAX_API_KEY",
|
||||
"storage": "api/.env",
|
||||
"type": "api_key"
|
||||
"description" : "OpenAI Audio Transcriptions 兼容 ASR Key;未单独配置 ASR_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env,不入库",
|
||||
"name" : "ASR_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description": "OpenAI-compatible GPT 图片模型 Key;未单独配置 IMAGE_API_KEY 时复用 LLM_API_KEY,本地开发只放 api/.env,不入库",
|
||||
"name": "IMAGE_API_KEY",
|
||||
"storage": "api/.env / deploy/.env.production",
|
||||
"type": "api_key"
|
||||
"description" : "OpenAI-compatible GPT 图片模型 Key;未单独配置 IMAGE_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env,不入库",
|
||||
"name" : "IMAGE_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description": "Azure OpenAI 协议语音/配音 Key;未单独配置 AZURE_OPENAI_API_KEY 时复用 LLM_API_KEY,本地开发只放 api/.env,不入库",
|
||||
"name": "AZURE_OPENAI_API_KEY",
|
||||
"storage": "api/.env / deploy/.env.production",
|
||||
"type": "api_key"
|
||||
"description" : "Azure OpenAI 协议语音\/配音 Key;未单独配置 AZURE_OPENAI_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env,不入库",
|
||||
"name" : "AZURE_OPENAI_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description": "SKG 豆包 / Seedance 视频生成 API Key,生产只放服务器 deploy/.env.production 的 VIDEO_API_KEY,本地开发放 api/.env,不入库",
|
||||
"name": "VIDEO_API_KEY",
|
||||
"storage": "api/.env / deploy/.env.production",
|
||||
"type": "api_key"
|
||||
"description" : "SKG 豆包 \/ Seedance 视频生成 API Key,生产只放服务器 deploy\/.env.production 的 VIDEO_API_KEY,本地开发放 api\/.env,不入库",
|
||||
"name" : "VIDEO_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description": "生产网页登录;用户名写 RULES.md,密码只放服务器 /root/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name": "WEB_LOGIN",
|
||||
"storage": "/root/skg-marketing-studio-login.txt / deploy/.env.production",
|
||||
"type": "web_login"
|
||||
"description" : "生产网页登录;用户名写 RULES.md,密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name" : "WEB_LOGIN",
|
||||
"storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production",
|
||||
"type" : "web_login"
|
||||
}
|
||||
],
|
||||
"description": "SKG 信息流广告快速复刻第一步:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后优先解析原音频,提取原文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜、元素生成和视频合成暂保留为后续能力,不作为当前开始流程的默认动作。",
|
||||
"kind": "app",
|
||||
"name": "SKG Marketing Studio / SKG 营销内容工作台",
|
||||
"ownership": "company",
|
||||
"pin_order": 1778664997,
|
||||
"pinned": true,
|
||||
"ports": [
|
||||
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
|
||||
"kind" : "app",
|
||||
"name" : "SKG Marketing Studio \/ SKG 营销内容工作台",
|
||||
"ownership" : "company",
|
||||
"pin_order" : 1778664997,
|
||||
"pinned" : true,
|
||||
"ports" : [
|
||||
{
|
||||
"fixed": true,
|
||||
"label": "web-dev",
|
||||
"port": 4290
|
||||
"fixed" : true,
|
||||
"label" : "web-dev",
|
||||
"port" : 4290
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"label": "api-dev",
|
||||
"port": 4291
|
||||
"fixed" : true,
|
||||
"label" : "api-dev",
|
||||
"port" : 4291
|
||||
}
|
||||
],
|
||||
"quick_login": {
|
||||
"label": "SKG Marketing Studio / SKG 营销内容工作台",
|
||||
"password": "c413cdc5bbbf2ca042",
|
||||
"url": "https://marketing.skg.com",
|
||||
"username": "skg"
|
||||
"quick_login" : {
|
||||
"label" : "SKG Marketing Studio \/ SKG 营销内容工作台",
|
||||
"password" : "c413cdc5bbbf2ca042",
|
||||
"url" : "https:\/\/marketing.skg.com",
|
||||
"username" : "skg"
|
||||
},
|
||||
"stack": [
|
||||
"Next.js + Python(yt-dlp/ffmpeg) + OpenAI-compatible LLM + GPT Image + Azure OpenAI TTS + Seedance"
|
||||
"stack" : [
|
||||
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
|
||||
],
|
||||
"status": "active",
|
||||
"urls": [
|
||||
"status" : "active",
|
||||
"urls" : [
|
||||
{
|
||||
"label": "production",
|
||||
"type": "app",
|
||||
"url": "https://marketing.skg.com"
|
||||
"label" : "production",
|
||||
"type" : "app",
|
||||
"url" : "https:\/\/marketing.skg.com"
|
||||
},
|
||||
{
|
||||
"label": "production-api",
|
||||
"type": "backend",
|
||||
"url": "https://marketing.skg.com/api"
|
||||
"label" : "production-api",
|
||||
"type" : "backend",
|
||||
"url" : "https:\/\/marketing.skg.com\/api"
|
||||
},
|
||||
{
|
||||
"label": "source-analysis",
|
||||
"type": "docs",
|
||||
"url": "docs/source-analysis.html"
|
||||
"label" : "agent-cut-preview",
|
||||
"type" : "app",
|
||||
"url" : "http:\/\/2.24.28.41:4290\/agent\/"
|
||||
},
|
||||
{
|
||||
"type": "repo",
|
||||
"label": "git",
|
||||
"url": "https://git.kang-kang.com/kangwan/20260512-skg-tk"
|
||||
"label" : "git",
|
||||
"type" : "repo",
|
||||
"url" : "https:\/\/git.kang-kang.com\/kangwan\/20260512-skg-tk"
|
||||
},
|
||||
{
|
||||
"label" : "git",
|
||||
"type" : "repo",
|
||||
"url" : "https:\/\/git.kang-kang.com"
|
||||
}
|
||||
],
|
||||
"worklog": {
|
||||
"auto": true,
|
||||
"path": "/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.memory/worklog.json"
|
||||
"worklog" : {
|
||||
"auto" : true,
|
||||
"path" : "\/Users\/kangwan\/Projects\/business\/20260512-20260512-skg-tk-二创验证\/.memory\/worklog.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl libgomp1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY api/requirements.txt /app/requirements.txt
|
||||
|
||||
86
RULES.md
86
RULES.md
@@ -11,21 +11,63 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后线性完成抽帧、分镜、元素生成、合成”的旧做法。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;当前暂停直接调视频模型,先逐条用“相似主体视图 + 产品素材池 + 首尾帧文字规划”生成并审核首帧/尾帧,保存规划后再决定哪些分镜进入单条视频候选。
|
||||
- 当前产品方向(2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路自动识别中文、英文和其他多语言原音频文案/字幕,统一补齐中文镜像,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
- 发布状态:已部署并验证(2026-05-15);`https://marketing.skg.com` 已启用应用内登录页,认证后首页 200,`/api/health` 返回 `ok:true`
|
||||
- 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-20,主体元素按套图文件夹分组展示,主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;工作台外层取消 1800x1000 固定画布和应用层 `zoom` 缩放,改为正常流式桌面容器,最低操作宽度 1280px;源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;转换层不再固定 640px 长高,按内容自然高度显示,仅以 560px 最大高度兜底内部滚动;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||
- 最近部署验证(2026-05-22):`6427935` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260522012756.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`)。部署后已把生产私有 `deploy/.env.production` 明确固定为多语言本地 ASR 路径并重启 API:`ASR_LANGUAGE=auto`、`FASTER_WHISPER_MODEL=base`、`ASR_REMOTE_ENABLED=false`、`ASR_LOCAL_FALLBACK_ENABLED=true`、`ASR_AUDIO_FALLBACK_ENABLED=false`;复验 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过,容器内 `/health` 确认 `asr_language=auto`、`faster_whisper=base`。
|
||||
- 最近部署验证(2026-05-21):`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302`、`web:/login/ 200`、`web:/_next/does-not-exist.js 404`、`web:/api/health 401`、`api:health ok`、`api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台` 和 `信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。
|
||||
- 最近部署验证(2026-05-20):`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]` 和 `max-w-[1920px]`,未再命中旧的 `h-[1000px]`、`w-[1800px]`、`BOARD_SCALE_PRESETS` 或 `boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
|
||||
- 最近部署验证(2026-05-20):`2b842fd` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520145223.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/743b82648dfa9db9.js` 已包含 `h-32`、`maxHeight:560`、`提示词就绪` 和 `确认生成`,且未再命中旧的 `height:640` / `h-40`;对应转换层取消固定长高,生成要求输入区回到 128px,底部仍由发送区主按钮确认生成。
|
||||
- 最近部署验证(2026-05-20):`ab31a98` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520144227.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/5bbecb6cf31316cb.js` 已包含 `h-40`、`提示词就绪` 和 `确认生成`,对应生成要求输入框加高到 160px,出图提示词生成后不再自动弹窗,底部主按钮直接切换为确认生成。
|
||||
- 最近部署验证(2026-05-20):`215987a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142849.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/54e1ee55c5019be8.js` 已包含 `height:640`,对应转换层固定高度从 560px 扩到 640px。
|
||||
- 最近部署验证(2026-05-20):`e1e9bf8` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142145.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测生成要求 composer:文本输入区实际高约 119px,张数控件和发送按钮实际高约 42px,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-expanded.png`。
|
||||
- 最近部署验证(2026-05-20):`45b25d0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520140706.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:生成要求区不再渲染“当前要求”、“提示词已生成”和“对话记录已收起”摘要,保留元素副本也已移除;该区只保留文本输入、张数控件和发送按钮,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-simplified.png`。
|
||||
- 最近部署验证(2026-05-20):`54f159b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520135509.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:转换层和参考帧池共用高度从 500px 拉到 560px,转换层内部改为 gap 堆叠并让主要板块 `shrink-0`,超出由转换层自身滚动承接;页面无客户端异常,验证截图 `/tmp/skg-conversion-stretched.png`。
|
||||
- 最近部署验证(2026-05-20):`d1e2b17` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520134529.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:内嵌“待确认提示词”卡和黑色“确认并生成 N 张”按钮均不再渲染,页面无客户端异常;有待确认 prompt 时只在“生成要求”标题右侧显示小型“待确认 · N 张”入口,验证截图 `/tmp/skg-conversion-no-inline-confirm.png`。
|
||||
- 最近部署验证(2026-05-20):`caa7b73` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520132820.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测:页面无客户端异常,源视频工作区已撤销“布局调节”按钮和 `localStorage["skg-source-workspace-layout:v1"]` 布局读写,固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 270px、参考帧池 140px、转换层 500px 内部滚动、主体空态 78px;验证截图 `/tmp/skg-layout-fixed-no-tuning.png`。
|
||||
- 最近部署验证(2026-05-20):`0db265f` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520131649.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 打开历史任务并展开“布局调节”:面板显示左列宽、视频高、时间轴高、参考池宽、转换层高、主体空态 6 个滑杆,调参值写入 `localStorage["skg-source-workspace-layout:v1"]`,供用户先在线试比例再固化默认值。
|
||||
- 最近部署验证(2026-05-20):`5bffd63` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520123949.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 复测 1440x900 与 2048x1060:历史任务加载后转换层占据主操作宽度,主体元素下移为转换层下方的紧凑结果栏,未再出现右侧三栏挤压;滚动到主体元素位置后仍能看到下方分镜工作台承接。
|
||||
- 最近部署验证(2026-05-20):`f0f567b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520120958.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 截图复测 1440x900、1728x1117、2048x1060、2560x1440:缩放后的工作台在 1440/1728/2560 这类高度有余量的窗口上下居中,2048x1060 保持顶部对齐并承接纵向内容,未出现先前的底部黑边失衡。
|
||||
- 最近部署验证(2026-05-20):`3e7c165` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520114759.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1366x768、1440x900、1728x1117、1920x1080、2048x1060、2200x1400、2560x1440:缩放档位分别为 0.72、0.8、0.92、1.06、1.06、1.16、1.34;2048x1060 保留左右 70px 呼吸感且无横向溢出,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`e33463e` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520113414.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测转换层:页面显示“生成要求”和“对话记录已收起”,不再显示旧标题“生图对话”,也不再渲染“我们将不再强制...”这类模型确认消息;最终英文 prompt 仍保留在“待确认提示词”区域,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`f35bfe0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520111824.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台仍按可见宽度优先铺满且外层 wrapper 左右间隙均为 0;内层画布已改用 CSS `zoom` 渲染,三个窗口分别为 `zoom=0.8/1.138/1.222`,`transform` 均为 `none`,避免整屏 transform 小数缩放造成文字发虚,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`1d0a77b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520105846.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台按可见宽度优先缩放,外层 wrapper 左右间隙均为 0;三个窗口分别缩放到 0.8、1.138、1.222,2048x1060 这类高度不足场景通过纵向滚动承接,不再为了完整高度留下左右空白,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`54eaac0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520104155.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台以 1800x1000 为基准分别缩放到 0.8、1.06、1.222,主网格列宽、源视频区列宽和三栏主体管线列宽保持一致,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`64fef5a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520102354.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 以 1440x900 与 2200x1400 两种窗口复测,工作台内部画布固定为 1800x1000,主网格列宽、源视频区列宽和三栏主体管线列宽一致,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`40f1f28` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520095941.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测首页正常渲染,浏览器 `pageerror` 为空。转换层不再显示固定快捷需求按钮,生图对话空态和输入框改为中性“保留识别元素 / 补充调整要求”,由识别结果 chip 和自然语言对话承接用户意图。
|
||||
- 最近部署验证(2026-05-20):`2c0e8a0` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520094923.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测首页正常渲染到工作台,浏览器 `pageerror` 为空;本次修复 `selectedAgentTraitsDirty` 残留变量名导致的客户端 `ReferenceError`,恢复转换层页面首屏渲染。
|
||||
- 最近部署验证(2026-05-20):`5bdde89` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520092721.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层识别结果 chip 改为本地即时切换:点亮表示保留元素、再次点击取消、清空按钮取消全部;点击 chip 不再触发 `/subject-agent/message`,保留元素随下一条“发送消息”一次性提交,避免每点一个特征都等待模型导致卡顿。
|
||||
- 最近部署验证(2026-05-20):`10d955c` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520090750.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层已移除可见快捷需求 chip,复刻/创新/卡通/人物占比作为对话默认意图写入输入提示;生成张数控件移到发送消息旁边,默认 6 张、当前支持 1-10;参考输入空态和已选参考图缩略图压小并继续复用 `MediaAssetTile` hover 放大预览。
|
||||
- 最近部署验证(2026-05-20):`b9c5511` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520085513.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层已更新为参考输入区支持左侧 `+`、参考帧拖拽、胶片拖拽和本地图片拖入,下方为生图对话消息 composer,右侧主体元素套图输出逻辑保持不变。
|
||||
- 最近部署验证(2026-05-19):`fd794e3` 已推送并部署到 `/opt/skg-marketing-studio`;生产 `/health` 显示 `image=gpt-image-2`、`subject_image=gpt-image-2`、`image_request_timeout_seconds=60`、`image_base_url=https://ai.skg.com/ezlink/v1`。容器内最小文字生图探针在 20 秒限制下返回 `ReadTimeout`,说明当前阻塞点是 `https://ai.skg.com/ezlink/v1` 的 `gpt-image-2` 上游通道超时,服务端不会更换图片模型。
|
||||
- 最近部署验证(2026-05-19):`3756259` 已推送并部署到 `/opt/skg-marketing-studio`;生产 `/health` 显示 `image=gpt-image-2`、`image_fallbacks=['gemini-3-pro-image-preview']`、`subject_image_fallbacks=['gpt-image-2','gemini-3-pro-image-preview']`、短时熔断阈值 2 次 / 600 秒。线上真实探针确认 `gpt-image-2` 读超时后同次调用可自动兜底到 `gemini-3-pro-image-preview` 并返回图片;模拟探针确认连续 2 次主模型失败后第三次直接走 Gemini。
|
||||
- 最近部署验证(2026-05-20):`c245bff` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py` 和 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web 容器 Up、API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。容器内模型偏好探针确认转换层 `image_model_preference` 路由:`auto -> ['gpt-image-2','gemini-3-pro-image-preview']`、`gpt-image-2 -> ['gpt-image-2']`、`gemini-3-pro-image-preview -> ['gemini-3-pro-image-preview']`。
|
||||
- 最近部署验证(2026-05-20):`2366662` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过。容器内后处理探针确认白底小主体保存为 `1152x2048` 时有效主体高度占比从约 0.60 可放大到 `0.906`,主体 6 视图 prompt 已注入同一份 pack bible。
|
||||
- 最近部署验证(2026-05-20):`7acbfd5` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层主体提示词记忆和生图模型偏好改为按 `job.id` 隔离;有参考帧的 `reconstruction_mode=similar` 会先生成 source brief,再把参考帧作为 `/images/edits` 的 `image[]` 参考提交;自主描述空文本切到 `reconstruction_mode=same` 源形象锁定路径。
|
||||
- 最近部署验证(2026-05-20):`e10b1a6` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为项目内生图对话智能体,新增 `Job.subject_agent` 和 `/subject-agent/analyze`、`/subject-agent/message`,GPT / Gemini 改为成套控制分析、对话和生图模型,数量与要求修改进入对话状态后再调用主体套图生成;Pydantic `model_bundle` protected namespace warning 已消除。
|
||||
- 最近部署验证(2026-05-20):`d82175f` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层去掉方向卡片、卡通风格下拉和独立数量按钮,保留单一参考区 + 生图对话;后端 `/subject-agent/message` 从对话中识别 `selected_mode` 和 `quantity` 后再驱动主体套图生成。
|
||||
- 最近部署验证(2026-05-20):`f1c710e` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层中间栏先清空为待重构占位,不再接收拖拽或触发 subject-agent / subject-assets;右侧主体元素输出逻辑保持不变。
|
||||
- 最近部署验证(2026-05-20):`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503,已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`。
|
||||
- 主站 / 前端:`https://marketing.skg.com`
|
||||
- API / 后端:`https://marketing.skg.com/api`
|
||||
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
|
||||
- 管理后台:待定
|
||||
- 服务器目录:`/opt/skg-marketing-studio`
|
||||
- 生产启动:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`
|
||||
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
||||
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
|
||||
- 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`;Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。
|
||||
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`,`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`
|
||||
- Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx,不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/`、`/_next/`、`/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。
|
||||
- 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
|
||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library`、`./data/prompt_library` 和 `./data/_trash`
|
||||
- TikTok 下载登录态:公开视频默认不带 cookies 直接下载,生产环境变量必须显式保持 `YTDLP_COOKIES_FILE=`、`YTDLP_COOKIES_FROM_BROWSER=` 为空,防止容器读取不存在的浏览器 cookies。只有 TikTok 明确要求登录态时,才使用服务器私有 cookies 文件 `./secrets/tiktok_cookies.txt` 挂载到 API 容器 `/run/secrets/tiktok_cookies.txt` 并配置 `YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt`;`yt-dlp` 会在任务结束时回写 cookies,因此不要把该挂载设为只读;不要使用云端浏览器读取方案,也不要把 cookies 入库。生产容器严禁使用 `YTDLP_COOKIES_FROM_BROWSER=chrome`。
|
||||
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
|
||||
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude:`.git`、`.memory`、`.logs`、`.pids`、`data`、`jobs`、`secrets`、`api/jobs`、`api/.env`、`api/.env.local`、`api/.env.production`、`deploy/.env.production`、`web/node_modules`、`web/.next`、`web/out`。不要把本地 `api/.env` 或 `deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
|
||||
|
||||
## 快捷登录
|
||||
- 登录地址:`https://marketing.skg.com/login/`
|
||||
@@ -50,29 +92,39 @@
|
||||
- 能联网和鉴权时必须 `git push origin main`;如果不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因
|
||||
|
||||
## 环境变量
|
||||
- `LLM_BASE_URL` / `LLM_API_KEY`:OpenAI 兼容网关,用于 ASR、翻译、文案改写、音频分析等文本/音频理解模型调用
|
||||
- `ASR_MODEL`:OpenAI Audio Transcriptions 音频转写模型,默认 `whisper-1`
|
||||
- `ASR_FALLBACK_MODEL`:远端 ASR 和本机 ASR 都不可用时才尝试的多模态兜底,默认 `gemini-2.5-flash`;如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
|
||||
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 音频分析单次请求超时,默认 45 秒,避免第一步长时间停在转录中
|
||||
- `LLM_BASE_URL` / `LLM_API_KEY`:OpenAI 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用
|
||||
- `ASR_BASE_URL` / `ASR_API_KEY`:OpenAI Audio Transcriptions 兼容网关,用于上传 `audio.wav` 做真实转写;未配置 `ASR_API_KEY` 时复用 `LLM_API_KEY`,生产默认指向 `https://ai.skg.com/azure/v1`
|
||||
- `ASR_MODEL`:OpenAI Audio Transcriptions 音频转写模型;微软通道使用 Azure OpenAI 部署名 `gpt-4o-transcribe`,如果 Azure 侧实际部署名不同必须同步改这里
|
||||
- `ASR_LANGUAGE`:远端和本地 ASR 的可选输入语言提示,默认空值/`auto`,由模型自动识别中文、英文和其他多语言;只有明确知道素材固定语种时才填写 ISO-639-1 代码强制识别。
|
||||
- `ASR_REMOTE_ENABLED`:是否启用远端 OpenAI Audio Transcriptions;微软 ASR 验收时必须为 `true`。当前生产因 `https://ai.skg.com/azure/v1` 下 `gpt-4o-transcribe` 返回 `DeploymentNotFound`,临时设为 `false`,直接走容器内 `faster-whisper`,等真实 Azure deployment 名补齐后再恢复。
|
||||
- `ASR_LOCAL_FALLBACK_ENABLED`:是否允许远端 ASR 失败后落到本机 / 容器内 ASR;当前生产为 `true`,复制本地成功路径的“本机真实转写”策略,云端用 CPU 版 `faster-whisper` 替代本机 Mac 的 `mlx_whisper`。
|
||||
- `ASR_AUDIO_FALLBACK_ENABLED`:是否允许远端和本机 ASR 失败后落到多模态音频兜底;生产微软 ASR 验收设为 `false`,避免静默使用 Gemini 音频
|
||||
- `FASTER_WHISPER_MODEL` / `FASTER_WHISPER_DEVICE` / `FASTER_WHISPER_COMPUTE_TYPE`:容器内本地 ASR 兜底,仅在 `ASR_LOCAL_FALLBACK_ENABLED=true` 时启用;默认用多语言 `base`,不要改回 `*.en` 英文专用模型,否则中文和多语言识别会退化。
|
||||
- `ASR_FALLBACK_MODEL`:多模态音频兜底模型,仅在 `ASR_AUDIO_FALLBACK_ENABLED=true` 时用于兜底或音频画像,默认 `gemini-2.5-flash`;如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
|
||||
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 翻译 / 音频分析单次请求超时;当前生产本地转写模式设为 45 秒,微软 ASR 重新启用时可按素材长度提高。
|
||||
- `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`
|
||||
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gemini-2.5-pro`
|
||||
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;当前第一步不默认调用口播改写,只保留原文案和声音分析
|
||||
- `GPT_TEXT_MODEL`:GPT 文本 / 视觉默认模型,默认 `gpt-4o`;用于兜底修正旧 Gemini 覆盖值
|
||||
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
|
||||
- `VISION_MODEL`:关键帧画面理解模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
|
||||
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;如果旧环境仍写 `gemini-*`,后端会自动改用 `REWRITE_MODEL`
|
||||
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
|
||||
- `PRODUCT_VIEW_MODEL`:同一产品素材池的视角标注/自动识别模型;当前按项目要求强制使用 `gpt-image-2`
|
||||
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`:OpenAI 兼容生图网关;当前所有生图入口一律强制使用 `gpt-image-2`,不做其他图片模型 fallback
|
||||
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2`
|
||||
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`:OpenAI 兼容生图网关;当前所有生图入口主模型仍为 `gpt-image-2`
|
||||
- `IMAGE_REQUEST_TIMEOUT_SECONDS`:单次图片网关请求超时,默认 60 秒;超时会直接把该视图标失败并继续下一张,避免主体 6 视图整包长时间无反馈
|
||||
- `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` 只回传是否配置代理,不回传代理地址。
|
||||
- `VOICE_PROVIDER`:配音通道,当前固定使用 `azure_openai`
|
||||
- `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 OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径
|
||||
- `MINIMAX_API_KEY`:MiniMax T2A 配音 Key,只能放本地 `api/.env`,不能入库;当前第一步暂不默认调用
|
||||
- `MINIMAX_TTS_BASE_URL` / `MINIMAX_TTS_MODEL` / `MINIMAX_TTS_VOICE_ID`:MiniMax 旧配音端点、模型和兜底音色配置,仅作为保留兼容;当前不作为默认语音通道
|
||||
- `MINIMAX_TTS_VOICE_POOL`:MiniMax 英文随机音色池;当前默认男声 `English_magnetic_voiced_man`、女声 `English_Upbeat_Woman`、成熟声 `English_MaturePartner`,供后续新配音阶段使用
|
||||
- `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,只能放本地环境变量
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
|
||||
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
|
||||
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
|
||||
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`;网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt`
|
||||
|
||||
## 规则
|
||||
- 不允许编造不存在的部署域名、账号、密码
|
||||
|
||||
@@ -17,16 +17,25 @@ LOCAL_ASR_BIN=/opt/homebrew/bin/mlx_whisper
|
||||
LOCAL_ASR_MODEL=mlx-community/whisper-tiny
|
||||
LOCAL_ASR_TIMEOUT_SECONDS=180
|
||||
TRANSLATE_MODEL=gemini-2.5-flash
|
||||
REWRITE_MODEL=gemini-2.5-pro
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
VISION_MODEL=gpt-4o
|
||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
IMAGE_API_KEY=
|
||||
IMAGE_MODEL=gpt-image-2
|
||||
IMAGE_REQUEST_TIMEOUT_SECONDS=60
|
||||
IMAGE_FALLBACK_ENABLED=true
|
||||
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
|
||||
IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
|
||||
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
|
||||
GPT_IMAGE_MODEL=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
|
||||
# 可选:本地网络需要代理访问 ai.skg.com 时配置;launchd 不一定继承 shell 代理变量。
|
||||
AI_HTTP_PROXY=
|
||||
YTDLP_COOKIES_FILE=
|
||||
YTDLP_COOKIES_FROM_BROWSER=
|
||||
VIDEO_MODEL=seedance
|
||||
VIDEO_MODEL_SEEDANCE=seedance-2-fast
|
||||
VIDEO_MODEL_KLING=kling-omni
|
||||
@@ -35,6 +44,7 @@ VIDEO_MODEL_VEO3=veo-3.1-fast
|
||||
# 音频文案改写 + Azure OpenAI 配音
|
||||
AUDIO_REWRITE_MODEL=gemini-2.5-pro
|
||||
AUDIO_PRODUCT_BRIEF="SKG 智能按摩产品,主打日常肩颈、腰背、眼部、膝盖或足部放松;广告表达要高级、干净、可信,不做医疗疗效承诺。"
|
||||
# 语音通道服务端固定为 Azure OpenAI。
|
||||
VOICE_PROVIDER=azure_openai
|
||||
AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure
|
||||
AZURE_OPENAI_API_KEY=
|
||||
@@ -42,13 +52,7 @@ AZURE_TTS_MODEL=gpt-4o-mini-tts
|
||||
AZURE_TTS_VOICE_ID=alloy
|
||||
AZURE_TTS_VOICE_POOL=alloy,verse,shimmer
|
||||
AZURE_TTS_PATH=/audio/speech
|
||||
|
||||
# MiniMax 旧配音通道,保留兼容;默认不走
|
||||
MINIMAX_API_KEY=
|
||||
MINIMAX_TTS_BASE_URL=https://api.minimax.io
|
||||
MINIMAX_TTS_MODEL=speech-2.8-turbo
|
||||
MINIMAX_TTS_VOICE_ID=English_expressive_narrator
|
||||
MINIMAX_TTS_VOICE_POOL=English_magnetic_voiced_man,English_Upbeat_Woman,English_MaturePartner
|
||||
AZURE_TTS_PATHS=/audio/speech,/v1/audio/speech
|
||||
|
||||
# Poe 视频 API(优先用于 Seedance / Kling / Veo)
|
||||
POE_API_BASE_URL=https://api.poe.com/v1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SKG TK 二创 API
|
||||
|
||||
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/英文 SKG 产品介绍文案 + MiniMax 英文配音管线。
|
||||
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/音频画像、抽帧、GPT 图像生成/修图、Azure OpenAI TTS 预留和视频候选预留管线。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -9,7 +9,7 @@ cd api
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # 按需填 LLM_API_KEY / MINIMAX_API_KEY
|
||||
cp .env.example .env # 按需填 LLM_API_KEY / AZURE_OPENAI_API_KEY
|
||||
uvicorn main:app --host 127.0.0.1 --port 4291
|
||||
```
|
||||
|
||||
@@ -18,21 +18,23 @@ uvicorn main:app --host 127.0.0.1 --port 4291
|
||||
## 路由
|
||||
|
||||
- `GET /health` — 健康检查 + 配置状态
|
||||
- `POST /jobs` `{url}` — 创建 job,后台下载源视频,视频就绪后可手动解析或提取音频
|
||||
- `POST /jobs` `{url}` — 创建 job,后台下载源视频;前端“开始分析”会在视频就绪后自动启动音频解析和视觉抽帧
|
||||
- `POST /jobs/{id}/download/retry` — TK 链接下载失败后重试下载;上传视频任务不能重下载
|
||||
- `GET /jobs/{id}` — 当前状态 + 产物;若原始音轨已拆出,会返回 `source_audio_url`
|
||||
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 英文产品介绍文案;文案长度按原音频时长估算,配置 MiniMax 后从英文随机音色池生成配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,可与抽帧并行,不自动触发
|
||||
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 中文翻译 + 讲话人 / 节奏 / 背景音分析;当前第一步不默认生成 SKG 新口播或 TTS 配音
|
||||
- `GET /jobs/{id}/video.mp4` — 原视频
|
||||
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形
|
||||
- `GET /jobs/{id}/audio-script.mp3` — 英文改写文案的 MiniMax 配音
|
||||
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张关键帧(0-9)
|
||||
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端音频波形和多模态音频分析使用
|
||||
- `GET /jobs/{id}/audio-script.mp3` — 后续新配音阶段保留的 Azure OpenAI TTS 文件
|
||||
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张参考帧;当前主流程自动抽 12 张动作 / 节奏参考帧,也支持手动按当前播放点补帧
|
||||
|
||||
## Mock 模式
|
||||
|
||||
未设 `LLM_API_KEY` 时,转录走本地 mock,便于 UI 联调;未设 `MINIMAX_API_KEY` 时只生成改写文案,不生成配音文件。
|
||||
未设 `LLM_API_KEY` 时,转录走本地 mock,便于 UI 联调;未设 `AZURE_OPENAI_API_KEY` 时,后续 TTS 文件不会生成,但不影响当前第一步音频解析。
|
||||
|
||||
## 依赖
|
||||
|
||||
- `ffmpeg` 系统二进制(拆轨 / 抽帧)
|
||||
- `yt-dlp` 系统二进制(也可走 Python 包)
|
||||
- OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写);如果 `/audio/transcriptions` 不可用,会用 `ASR_FALLBACK_MODEL` 走 Gemini 多模态音频识别
|
||||
- MiniMax T2A HTTP(英文产品介绍文案配音,使用 `MINIMAX_API_KEY`;默认随机音色池 `English_magnetic_voiced_man,English_Upbeat_Woman,English_MaturePartner`)
|
||||
- OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写 / 视觉 brief);ASR 默认自动识别中文、英文和其他多语言,远端失败后先走容器内多语言 `faster-whisper` / 本机 `mlx_whisper`,再按开关用 `ASR_FALLBACK_MODEL` 走多模态音频识别,后端会拒绝疑似假字幕或覆盖率过低的时间轴
|
||||
- GPT 图片网关(当前所有生图 / 修图 / 产品视角识别 / 主体资产 / 首尾帧都强制使用 `gpt-image-2`,不做其他图片模型 fallback)
|
||||
- Azure OpenAI TTS(后续新配音阶段使用 `AZURE_OPENAI_API_KEY`;默认模型 `gpt-4o-mini-tts`,按 `AZURE_TTS_PATHS` 依次尝试语音路径)
|
||||
|
||||
3
api/asset_library/index.json
Normal file
3
api/asset_library/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"name": "运动阳光男",
|
||||
"folder": "01_运动阳光男",
|
||||
"description": "运动阳光男透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||
"prompt_brief": "Athletic sunny male transparent wellness character, young adult energy, lean fit proportions, open and upbeat posture, clean translucent skin shell with visible white skeleton. The character should feel friendly, active, outdoor-sport inspired, bright, healthy, and suitable for premium SKG neck-and-shoulder wearable device ads. Keep neck, collarbone, shoulders, upper back, and cervical spine readable without bulky clothing or props.",
|
||||
"primary_image": "character-01-front",
|
||||
"images": [
|
||||
{
|
||||
@@ -80,6 +81,7 @@
|
||||
"name": "都市型男",
|
||||
"folder": "02_都市型男",
|
||||
"description": "都市型男透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||
"prompt_brief": "Urban stylish male transparent wellness character, adult metropolitan feel, clean confident posture, refined proportions, translucent body shell with visible white skeleton. The commercial mood is premium city lifestyle, composed, sharp, and modern, suitable for office or commute-oriented SKG neck-and-shoulder massage ads. Keep shoulder line, side neck, collarbone, and upper back clear for wearable device placement.",
|
||||
"primary_image": "character-02-front",
|
||||
"images": [
|
||||
{
|
||||
@@ -152,6 +154,7 @@
|
||||
"name": "优雅白领女",
|
||||
"folder": "03_优雅白领女",
|
||||
"description": "优雅白领女透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||
"prompt_brief": "Elegant professional female transparent wellness character, young adult to adult office-worker mood, slim balanced proportions, calm poised posture, translucent outer body with a clean visible white skeleton. The style should feel premium, gentle, trustworthy, and workplace-friendly for SKG neck-and-shoulder wearable device ads. Keep hair, collars, and accessories from hiding the neck, shoulders, collarbone, upper back, and cervical spine.",
|
||||
"primary_image": "character-03-front",
|
||||
"images": [
|
||||
{
|
||||
@@ -224,6 +227,7 @@
|
||||
"name": "运动辣妹",
|
||||
"folder": "04_运动辣妹",
|
||||
"description": "运动辣妹透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||
"prompt_brief": "Sporty confident female transparent wellness character, energetic young adult fitness mood, toned proportions, expressive posture, translucent skin shell with visible white skeleton. The character should feel active, fashionable, bright, and creator-ad friendly while remaining premium and non-horror. Keep the neck, side neck, shoulders, collarbone, upper trapezius, and upper back open and readable for SKG wearable massage device scenes.",
|
||||
"primary_image": "character-04-front",
|
||||
"images": [
|
||||
{
|
||||
@@ -296,6 +300,7 @@
|
||||
"name": "绅士大叔",
|
||||
"folder": "05_绅士大叔",
|
||||
"description": "绅士大叔透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||
"prompt_brief": "Mature gentleman transparent wellness character, adult to middle-aged presence without exact age, steady confident posture, slightly stronger build, translucent body shell with a clean visible white skeleton. The commercial mood is calm, trustworthy, premium, and lifestyle-oriented for SKG neck-and-shoulder wearable device ads. Keep collars and styling minimal so the neck, shoulders, upper back, cervical spine, and shoulder blades remain visible.",
|
||||
"primary_image": "character-05-front",
|
||||
"images": [
|
||||
{
|
||||
@@ -364,4 +369,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3702
api/main.py
3702
api/main.py
File diff suppressed because it is too large
Load Diff
3
api/prompt_library/index.json
Normal file
3
api/prompt_library/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -6,6 +6,8 @@ python-dotenv==1.0.1
|
||||
yt-dlp==2026.3.17
|
||||
openai==1.55.3
|
||||
httpx==0.27.2
|
||||
requests==2.32.5
|
||||
imagehash==4.3.1
|
||||
Pillow>=11.0
|
||||
numpy>=2.0
|
||||
faster-whisper==1.1.1
|
||||
|
||||
0
asset_library/.gitkeep
Normal file
0
asset_library/.gitkeep
Normal file
@@ -3,6 +3,8 @@
|
||||
|
||||
# Runtime
|
||||
JOBS_DIR=/data/jobs
|
||||
ASSET_LIBRARY_DIR=/data/asset_library
|
||||
PROMPT_LIBRARY_DIR=/data/prompt_library
|
||||
KEYFRAME_COUNT=12
|
||||
CORS_ORIGINS=https://marketing.skg.com
|
||||
API_PORT=4291
|
||||
@@ -19,23 +21,47 @@ LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
LLM_API_KEY=
|
||||
|
||||
# Model routing
|
||||
ASR_MODEL=whisper-1
|
||||
# Azure ASR can be re-enabled once the real deployment name exists.
|
||||
ASR_BASE_URL=https://ai.skg.com/azure/v1
|
||||
ASR_API_KEY=
|
||||
ASR_MODEL=gpt-4o-transcribe
|
||||
ASR_LANGUAGE=en
|
||||
ASR_REMOTE_ENABLED=false
|
||||
ASR_LOCAL_FALLBACK_ENABLED=true
|
||||
ASR_AUDIO_FALLBACK_ENABLED=false
|
||||
ASR_FALLBACK_MODEL=gemini-2.5-flash
|
||||
ASR_TIMEOUT_SECONDS=45
|
||||
FASTER_WHISPER_MODEL=tiny.en
|
||||
FASTER_WHISPER_DEVICE=cpu
|
||||
FASTER_WHISPER_COMPUTE_TYPE=int8
|
||||
TRANSLATE_MODEL=gemini-2.5-flash
|
||||
REWRITE_MODEL=gemini-2.5-pro
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
VISION_MODEL=gpt-4o
|
||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
IMAGE_API_KEY=
|
||||
IMAGE_MODEL=gpt-image-2
|
||||
IMAGE_REQUEST_TIMEOUT_SECONDS=60
|
||||
IMAGE_FALLBACK_ENABLED=true
|
||||
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
|
||||
IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
|
||||
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
|
||||
GPT_IMAGE_MODEL=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
|
||||
# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking.
|
||||
AI_HTTP_PROXY=
|
||||
|
||||
# Optional TikTok download login state for yt-dlp. Keep cookies files private.
|
||||
# Leave blank for public TikTok videos. Set to /run/secrets/tiktok_cookies.txt only when a link explicitly requires login cookies.
|
||||
YTDLP_COOKIES_FILE=
|
||||
YTDLP_COOKIES_FROM_BROWSER=
|
||||
|
||||
# Audio rewrite and Azure OpenAI TTS
|
||||
AUDIO_REWRITE_MODEL=gemini-2.5-pro
|
||||
AUDIO_PRODUCT_BRIEF="SKG smart massage products for daily neck, shoulder, back, eye, knee, and foot relaxation. Keep claims premium, clean, credible, and non-medical."
|
||||
# Voice is fixed to Azure OpenAI in the backend.
|
||||
VOICE_PROVIDER=azure_openai
|
||||
AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure
|
||||
AZURE_OPENAI_API_KEY=
|
||||
@@ -43,13 +69,7 @@ AZURE_TTS_MODEL=gpt-4o-mini-tts
|
||||
AZURE_TTS_VOICE_ID=alloy
|
||||
AZURE_TTS_VOICE_POOL=alloy,verse,shimmer
|
||||
AZURE_TTS_PATH=/audio/speech
|
||||
|
||||
# Legacy MiniMax TTS fallback; not the default voice provider.
|
||||
MINIMAX_API_KEY=
|
||||
MINIMAX_TTS_BASE_URL=https://api.minimax.io
|
||||
MINIMAX_TTS_MODEL=speech-2.8-turbo
|
||||
MINIMAX_TTS_VOICE_ID=English_expressive_narrator
|
||||
MINIMAX_TTS_VOICE_POOL=English_magnetic_voiced_man,English_Upbeat_Woman,English_MaturePartner
|
||||
AZURE_TTS_PATHS=/audio/speech,/v1/audio/speech
|
||||
|
||||
# Video generation. Use SKG Doubao / Seedance gateway in production.
|
||||
POE_API_BASE_URL=https://api.poe.com/v1
|
||||
|
||||
@@ -10,9 +10,17 @@ services:
|
||||
- ./deploy/.env.production
|
||||
environment:
|
||||
JOBS_DIR: /data/jobs
|
||||
AGENT_RUNS_DIR: /data/agent_runs
|
||||
ASSET_LIBRARY_DIR: /data/asset_library
|
||||
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||
CORS_ORIGINS: https://marketing.skg.com
|
||||
volumes:
|
||||
- ./data/jobs:/data/jobs
|
||||
- ./data/agent_runs:/data/agent_runs
|
||||
- ./data/asset_library:/data/asset_library
|
||||
- ./data/prompt_library:/data/prompt_library
|
||||
- ./data/_trash:/data/_trash
|
||||
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- skg-marketing-internal
|
||||
|
||||
47
docker-compose.standalone.yml
Normal file
47
docker-compose.standalone.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: skg-agent-cut
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
container_name: skg-agent-api
|
||||
env_file:
|
||||
- ./deploy/.env.production
|
||||
environment:
|
||||
JOBS_DIR: /data/jobs
|
||||
AGENT_RUNS_DIR: /data/agent_runs
|
||||
ASSET_LIBRARY_DIR: /data/asset_library
|
||||
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||
CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290
|
||||
volumes:
|
||||
- ./data/jobs:/data/jobs
|
||||
- ./data/agent_runs:/data/agent_runs
|
||||
- ./data/asset_library:/data/asset_library
|
||||
- ./data/prompt_library:/data/prompt_library
|
||||
- ./data/_trash:/data/_trash
|
||||
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
skg-agent-internal:
|
||||
aliases:
|
||||
- skg-marketing-api
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
NEXT_PUBLIC_API_BASE: /api
|
||||
container_name: skg-agent-web
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "0.0.0.0:4290:80"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- skg-agent-internal
|
||||
|
||||
networks:
|
||||
skg-agent-internal:
|
||||
name: skg-agent-internal
|
||||
File diff suppressed because one or more lines are too long
0
prompt_library/.gitkeep
Normal file
0
prompt_library/.gitkeep
Normal file
71
scripts/deploy-prod-safe.sh
Executable file
71
scripts/deploy-prod-safe.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${HOST:-root@76.13.31.179}"
|
||||
APP_DIR="${APP_DIR:-/opt/skg-marketing-studio}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/opt/skg-marketing-studio-backups}"
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if [[ "${1:-}" == "--no-build" ]]; then
|
||||
BUILD_FLAG=""
|
||||
else
|
||||
BUILD_FLAG="--build"
|
||||
fi
|
||||
|
||||
echo "==> Preflight: creating remote data/env backup"
|
||||
ssh "$HOST" "set -euo pipefail
|
||||
cd '$APP_DIR'
|
||||
mkdir -p '$BACKUP_DIR'
|
||||
stamp=\$(date +%Y%m%d%H%M%S)
|
||||
tar -czf '$BACKUP_DIR/skg-marketing-preserve-'\$stamp'.tgz' \
|
||||
deploy/.env.production \
|
||||
data/jobs \
|
||||
data/asset_library \
|
||||
data/prompt_library \
|
||||
data/_trash \
|
||||
secrets 2>/tmp/skg-backup-warnings.log || {
|
||||
cat /tmp/skg-backup-warnings.log >&2 || true
|
||||
exit 1
|
||||
}
|
||||
find '$BACKUP_DIR' -name 'skg-marketing-preserve-*.tgz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
|
||||
echo backup:\$(ls -t '$BACKUP_DIR'/skg-marketing-preserve-*.tgz | head -1)
|
||||
"
|
||||
|
||||
echo "==> Syncing code with production data protected"
|
||||
rsync -az --delete \
|
||||
--filter='P /data/***' \
|
||||
--filter='P /jobs/***' \
|
||||
--filter='P /secrets/***' \
|
||||
--filter='P /deploy/.env.production' \
|
||||
--filter='P /api/jobs/***' \
|
||||
--filter='P /api/.env' \
|
||||
--filter='P /api/.env.local' \
|
||||
--filter='P /api/.env.production' \
|
||||
--exclude='/.git/' \
|
||||
--exclude='/.memory/' \
|
||||
--exclude='/.logs/' \
|
||||
--exclude='/.pids/' \
|
||||
--exclude='/data/' \
|
||||
--exclude='/jobs/' \
|
||||
--exclude='/secrets/' \
|
||||
--exclude='/api/jobs/' \
|
||||
--exclude='/api/.env' \
|
||||
--exclude='/api/.env.local' \
|
||||
--exclude='/api/.env.production' \
|
||||
--exclude='/deploy/.env.production' \
|
||||
--exclude='/web/node_modules/' \
|
||||
--exclude='/web/.next/' \
|
||||
--exclude='/web/out/' \
|
||||
--exclude='/node_modules/' \
|
||||
--exclude='内部分享-口播脚本.md' \
|
||||
./ "$HOST:$APP_DIR/"
|
||||
|
||||
echo "==> Rebuilding production containers"
|
||||
ssh "$HOST" "cd '$APP_DIR' && docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d $BUILD_FLAG"
|
||||
|
||||
echo "==> Verifying production"
|
||||
"$ROOT_DIR/scripts/verify-prod-docker.sh" "$HOST"
|
||||
|
||||
echo "==> Done"
|
||||
29
scripts/verify-prod-docker.sh
Executable file
29
scripts/verify-prod-docker.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-root@76.13.31.179}"
|
||||
APP_DIR="${APP_DIR:-/opt/skg-marketing-studio}"
|
||||
|
||||
ssh "$HOST" "cd '$APP_DIR' && \
|
||||
docker ps --filter name=skg-marketing --format '{{.Names}} {{.Status}}' && \
|
||||
docker exec skg-marketing-web sh -lc '
|
||||
set -e
|
||||
echo web:no_local_api_refs
|
||||
if grep -Rao \"http://localhost:4291\\|http://127.0.0.1:4291\\|localhost:4290\\|127.0.0.1:4290\" /usr/share/nginx/html/_next/static 2>/dev/null | head -1 | grep -q .; then
|
||||
echo \"ERROR: local API/dev URL leaked into web static bundle\" >&2
|
||||
exit 1
|
||||
fi
|
||||
for p in / /login/ /_next/does-not-exist.js /api/health; do
|
||||
code=\$(curl -sS -o /tmp/skg-smoke.out -w \"%{http_code}\" \"http://127.0.0.1\$p\")
|
||||
case \"\$p:\$code\" in
|
||||
/:302|/login/:200|/_next/does-not-exist.js:404|/api/health:401) echo \"web:\$p \$code\" ;;
|
||||
*) echo \"ERROR: unexpected web route status \$p \$code\" >&2; head -c 200 /tmp/skg-smoke.out >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
' && \
|
||||
docker exec skg-marketing-api sh -lc '
|
||||
set -e
|
||||
test ! -f /app/.env || { echo \"ERROR: /app/.env leaked into API image\" >&2; exit 1; }
|
||||
python -c \"import main; assert main.YTDLP_COOKIES_FROM_BROWSER == \\\"\\\", main.YTDLP_COOKIES_FROM_BROWSER; print(\\\"api:ytdlp_cookie_args\\\", main.ytdlp_cookie_args())\"
|
||||
curl -sS http://127.0.0.1:4291/health | python -c \"import json,sys; d=json.load(sys.stdin); assert d[\\\"ok\\\"] is True; assert d[\\\"auth_configured\\\"] is True; print(\\\"api:health ok\\\")\"
|
||||
'"
|
||||
332
web/app/agent/page.tsx
Normal file
332
web/app/agent/page.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
Film,
|
||||
ImagePlus,
|
||||
Link2,
|
||||
Loader2,
|
||||
Play,
|
||||
RotateCcw,
|
||||
TerminalSquare,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"
|
||||
|
||||
type AgentRunLog = {
|
||||
ts: number
|
||||
level: "info" | "warn" | "error"
|
||||
message: string
|
||||
}
|
||||
|
||||
type AgentRun = {
|
||||
id: string
|
||||
job_id: string
|
||||
status: "draft" | "queued" | "executing" | "reviewing" | "completed" | "failed"
|
||||
stage: string
|
||||
progress: number
|
||||
logs: AgentRunLog[]
|
||||
video_ids: string[]
|
||||
final_video_url: string
|
||||
contact_sheet_url: string
|
||||
error: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
const STAGES = [
|
||||
{ key: "download", label: "下载" },
|
||||
{ key: "assets", label: "素材" },
|
||||
{ key: "analyze", label: "拆解" },
|
||||
{ key: "plan", label: "规划" },
|
||||
{ key: "execute", label: "生成" },
|
||||
{ key: "review", label: "审片" },
|
||||
{ key: "compose", label: "合成" },
|
||||
{ key: "final", label: "成片" },
|
||||
]
|
||||
|
||||
function formatClock(ts: number) {
|
||||
if (!ts) return "--:--:--"
|
||||
return new Date(ts * 1000).toLocaleTimeString("zh-CN", { hour12: false })
|
||||
}
|
||||
|
||||
function runVideoUrl(run: AgentRun | null) {
|
||||
if (!run?.final_video_url) return ""
|
||||
return `${API_BASE}${run.final_video_url}`
|
||||
}
|
||||
|
||||
function runContactUrl(run: AgentRun | null) {
|
||||
if (!run?.contact_sheet_url) return ""
|
||||
return `${API_BASE}${run.contact_sheet_url}`
|
||||
}
|
||||
|
||||
export default function AgentPage() {
|
||||
const [url, setUrl] = useState("")
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [run, setRun] = useState<AgentRun | null>(null)
|
||||
const [recent, setRecent] = useState<AgentRun[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
|
||||
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((items: AgentRun[]) => {
|
||||
setRecent(items)
|
||||
const latest = items.find((item) => item.status === "executing" || item.status === "reviewing" || item.status === "completed")
|
||||
if (latest) setRun(latest)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!run || run.status === "completed" || run.status === "failed") return
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/agent-runs/${run.id}`, { cache: "no-store" })
|
||||
if (!res.ok) return
|
||||
const next = await res.json()
|
||||
setRun(next)
|
||||
} catch {
|
||||
/* keep current state */
|
||||
}
|
||||
}, 2000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [run?.id, run?.status])
|
||||
|
||||
useEffect(() => {
|
||||
const el = terminalRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [run?.logs.length])
|
||||
|
||||
async function submit() {
|
||||
setError("")
|
||||
if (!url.trim()) {
|
||||
setError("需要 TikTok 链接")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append("tk_url", url.trim())
|
||||
files.slice(0, 6).forEach((file) => form.append("product_files", file))
|
||||
const res = await fetch(`${API_BASE}/agent-runs`, { method: "POST", body: form })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "")
|
||||
throw new Error(text.slice(0, 260) || `HTTP ${res.status}`)
|
||||
}
|
||||
const created = await res.json()
|
||||
setRun(created)
|
||||
setRecent((prev) => [created, ...prev.filter((item) => item.id !== created.id)].slice(0, 8))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeStageIndex = run ? Math.max(0, STAGES.findIndex((item) => item.key === run.stage)) : -1
|
||||
const canStart = !!url.trim() && !submitting
|
||||
const videoSrc = runVideoUrl(run)
|
||||
const contactSrc = runContactUrl(run)
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f3f4f7] text-[#111318]">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[1720px] flex-col gap-5 px-5 py-5">
|
||||
<header className="flex items-center justify-between rounded-[28px] border border-black/5 bg-white/80 px-5 py-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||
<div>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-[0.18em] text-[#7b8190]">SKG Agent Cut</div>
|
||||
<h1 className="mt-1 text-[26px] font-semibold tracking-normal text-[#111318]">一分钟二创出片终端</h1>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 rounded-full bg-[#111318] px-3 py-2 text-[12px] font-medium text-white md:flex">
|
||||
<TerminalSquare className="h-4 w-4 text-[#81d4ff]" />
|
||||
{run ? `${run.status} · ${run.progress}%` : "standby"}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid min-h-[calc(100vh-128px)] grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(520px,1fr)_420px]">
|
||||
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
|
||||
<label className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
|
||||
<Link2 className="h-4 w-4 text-[#0a84ff]" />
|
||||
TikTok 链接
|
||||
</label>
|
||||
<textarea
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://www.tiktok.com/@..."
|
||||
className="h-28 w-full resize-none rounded-[18px] border border-[#d9dee8] bg-white px-4 py-3 text-[14px] leading-relaxed text-[#111318] outline-none transition focus:border-[#0a84ff] focus:ring-4 focus:ring-[#0a84ff]/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
|
||||
<label className="mb-3 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
|
||||
<ImagePlus className="h-4 w-4 text-[#34c759]" />
|
||||
产品图
|
||||
</label>
|
||||
<label className="flex h-32 cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7ceda] bg-white text-center transition hover:border-[#0a84ff] hover:bg-[#f7fbff]">
|
||||
<Upload className="mb-2 h-6 w-6 text-[#7b8190]" />
|
||||
<span className="text-[13px] font-medium text-[#2b3038]">上传产品图</span>
|
||||
<span className="mt-1 text-[12px] text-[#7b8190]">最多 6 张</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const next = Array.from(e.target.files ?? []).slice(0, 6)
|
||||
setFiles(next)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{previews.length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{previews.map((item) => (
|
||||
<div key={`${item.file.name}-${item.file.size}`} className="aspect-square overflow-hidden rounded-[14px] border border-black/5 bg-white">
|
||||
<img src={item.url} alt={item.file.name} className="h-full w-full object-contain" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-[18px] border border-[#ff453a]/20 bg-[#ff453a]/10 px-4 py-3 text-[13px] text-[#9f1d17]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canStart}
|
||||
onClick={submit}
|
||||
className="flex h-14 items-center justify-center gap-2 rounded-[20px] bg-[#111318] text-[15px] font-semibold text-white shadow-[0_16px_40px_rgba(17,19,24,0.18)] transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#b8bec8]"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <Play className="h-5 w-5" />}
|
||||
开始出片
|
||||
</button>
|
||||
|
||||
<div className="mt-auto rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-3">
|
||||
<div className="mb-2 text-[12px] font-semibold text-[#7b8190]">最近任务</div>
|
||||
<div className="space-y-2">
|
||||
{recent.slice(0, 4).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setRun(item)}
|
||||
className="flex w-full items-center justify-between rounded-[16px] bg-white px-3 py-2 text-left text-[12px] text-[#2b3038] transition hover:bg-[#f1f5fb]"
|
||||
>
|
||||
<span className="font-medium">{item.id}</span>
|
||||
<span className="text-[#7b8190]">{item.status}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-[680px] flex-col rounded-[30px] border border-black/5 bg-[#111318] p-4 shadow-[0_24px_80px_rgba(20,25,38,0.16)]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[16px] bg-white/8">
|
||||
<TerminalSquare className="h-5 w-5 text-[#81d4ff]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[16px] font-semibold text-white">Agent Terminal</h2>
|
||||
<p className="text-[12px] text-white/45">{run ? `run ${run.id} · job ${run.job_id}` : "waiting for input"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{run?.status === "failed" ? (
|
||||
<CircleAlert className="h-5 w-5 text-[#ff453a]" />
|
||||
) : run?.status === "completed" ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-[#34c759]" />
|
||||
) : (
|
||||
<Loader2 className={`h-5 w-5 text-[#81d4ff] ${run ? "animate-spin" : ""}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-4 gap-2 lg:grid-cols-8">
|
||||
{STAGES.map((stage, index) => {
|
||||
const active = index <= activeStageIndex || run?.status === "completed"
|
||||
return (
|
||||
<div key={stage.key} className={`rounded-[14px] px-3 py-2 text-[12px] ${active ? "bg-white text-[#111318]" : "bg-white/6 text-white/40"}`}>
|
||||
{stage.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 h-2 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-full rounded-full bg-[#34c759] transition-all duration-700" style={{ width: `${run?.progress ?? 0}%` }} />
|
||||
</div>
|
||||
|
||||
<div ref={terminalRef} className="min-h-0 flex-1 overflow-auto rounded-[22px] border border-white/8 bg-black px-4 py-4 font-mono text-[12px] leading-relaxed text-[#d8f3dc]">
|
||||
{!run && <div className="text-white/35">$ idle</div>}
|
||||
{run?.logs.map((log, index) => (
|
||||
<div key={`${log.ts}-${index}`} className={log.level === "error" ? "text-[#ff8a80]" : log.level === "warn" ? "text-[#ffd166]" : "text-[#d8f3dc]"}>
|
||||
<span className="text-white/30">[{formatClock(log.ts)}]</span> {log.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-[0.16em] text-[#7b8190]">Final</div>
|
||||
<h2 className="mt-1 text-[18px] font-semibold text-[#111318]">成片播放器</h2>
|
||||
</div>
|
||||
<Film className="h-5 w-5 text-[#ff9f0a]" />
|
||||
</div>
|
||||
|
||||
<div className="aspect-[9/16] overflow-hidden rounded-[26px] border border-black/8 bg-[#111318]">
|
||||
{videoSrc ? (
|
||||
<video key={videoSrc} src={videoSrc} controls playsInline className="h-full w-full bg-black object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#7b8190]">
|
||||
<Film className="h-8 w-8" />
|
||||
<span className="text-[13px]">等待成片</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contactSrc && (
|
||||
<div className="overflow-hidden rounded-[18px] border border-black/8 bg-white">
|
||||
<img src={contactSrc} alt="final contact sheet" className="w-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<a
|
||||
href={videoSrc || undefined}
|
||||
download
|
||||
className={`flex h-11 items-center justify-center gap-2 rounded-[16px] text-[13px] font-semibold ${videoSrc ? "bg-[#0a84ff] text-white" : "pointer-events-none bg-[#dfe3ea] text-[#8d94a1]"}`}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4" />
|
||||
下载
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRun(null)
|
||||
setError("")
|
||||
}}
|
||||
className="flex h-11 items-center justify-center gap-2 rounded-[16px] bg-[#eef1f6] text-[13px] font-semibold text-[#2b3038] transition hover:bg-[#e3e7ef]"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重来
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -464,6 +464,688 @@ nextjs-portal {
|
||||
min-width: 0;
|
||||
transform: translateY(44px);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
信息流工作台 · 登录页同源质感
|
||||
============================================================ */
|
||||
.skg-board-theme {
|
||||
--skg-gold-1: #c8cd19;
|
||||
--skg-gold-2: #a2c638;
|
||||
--skg-cream: #f6f6ee;
|
||||
--skg-bg-1: #1b1b1b;
|
||||
--skg-bg-2: #242424;
|
||||
--skg-bg-3: rgba(255, 255, 255, 0.1);
|
||||
--skg-border: rgba(255, 255, 255, 0.14);
|
||||
--skg-text-1: #ffffff;
|
||||
--skg-text-2: rgba(255, 255, 255, 0.56);
|
||||
--skg-text-3: rgba(255, 255, 255, 0.36);
|
||||
--skg-success: #a2c638;
|
||||
--skg-warn: #c8cd19;
|
||||
--skg-danger: #fb7185;
|
||||
--skg-info: #a6d533;
|
||||
--skg-radius-sm: 6px;
|
||||
--skg-radius-md: 8px;
|
||||
--skg-radius-lg: 20px;
|
||||
--skg-shadow-button: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||
--skg-shadow-card: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||
--skg-glass-bg: rgba(255, 255, 255, 0.1);
|
||||
--skg-glass-bg-soft: rgba(255, 255, 255, 0.055);
|
||||
--skg-rail: #383838;
|
||||
--skg-wave-bg: rgba(0, 0, 0, 0.35);
|
||||
--skg-wave-fill: rgba(209, 213, 219, 0.74);
|
||||
--skg-wave-stroke-1: rgba(229, 231, 235, 0.7);
|
||||
--skg-wave-stroke-2: rgba(229, 231, 235, 0.52);
|
||||
--skg-wave-grid: rgba(255, 255, 255, 0.14);
|
||||
--skg-wave-marker: rgba(255, 255, 255, 0.12);
|
||||
--skg-wave-hover: rgba(207, 250, 254, 0.7);
|
||||
--skg-wave-playhead: #a7f3d0;
|
||||
--skg-wave-playhead-shadow: rgba(110, 231, 183, 0.85);
|
||||
color: var(--skg-text-1);
|
||||
background:
|
||||
radial-gradient(circle at 20% 18%, rgba(162, 198, 56, 0.09), transparent 28%),
|
||||
radial-gradient(circle at 86% 78%, rgba(200, 205, 25, 0.1), transparent 28%),
|
||||
linear-gradient(120deg, #171717 0%, #202020 48%, #101010 100%);
|
||||
}
|
||||
|
||||
.skg-board-theme::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.016) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.skg-board-theme::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.2), transparent 45%, rgba(0, 0, 0, 0.42)),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.38), transparent 36%, rgba(0, 0, 0, 0.22));
|
||||
}
|
||||
|
||||
.skg-board-ambient {
|
||||
background:
|
||||
radial-gradient(circle at 72% 12%, rgba(162, 198, 56, 0.13), transparent 28%),
|
||||
radial-gradient(circle at 18% 92%, rgba(200, 205, 25, 0.12), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-board-topbar,
|
||||
.skg-board-panel {
|
||||
border-color: var(--skg-border) !important;
|
||||
background:
|
||||
radial-gradient(circle at 88% 22%, rgba(162, 198, 56, 0.06), transparent 38%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.032)),
|
||||
rgba(30, 30, 30, 0.78) !important;
|
||||
box-shadow: var(--skg-shadow-card);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.skg-board-topbar {
|
||||
background:
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.038) 58%, rgba(162, 198, 56, 0.07)),
|
||||
rgba(28, 28, 28, 0.84) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme input:focus,
|
||||
.skg-board-theme textarea:focus,
|
||||
.skg-board-theme select:focus {
|
||||
border-color: rgba(214, 179, 106, 0.58) !important;
|
||||
box-shadow: 0 0 0 2px rgba(162, 198, 56, 0.18);
|
||||
}
|
||||
|
||||
.skg-board-theme input[type="checkbox"] {
|
||||
accent-color: #d6b36a;
|
||||
}
|
||||
|
||||
.skg-board-theme ::selection {
|
||||
background: rgba(214, 179, 106, 0.28);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skg-board-shell {
|
||||
min-height: calc(100vh - 32px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.026), rgba(255, 255, 255, 0.01)),
|
||||
rgba(18, 18, 18, 0.72);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.skg-board-rail {
|
||||
width: 65px;
|
||||
height: calc(100vh - 64px);
|
||||
min-height: 600px;
|
||||
max-height: calc(100vh - 32px);
|
||||
align-self: flex-start;
|
||||
top: 16px;
|
||||
z-index: 30;
|
||||
overflow: visible;
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
|
||||
.skg-board-rail.is-open {
|
||||
width: 397px;
|
||||
}
|
||||
|
||||
.skg-board-rail__strip {
|
||||
width: 65px;
|
||||
height: 100%;
|
||||
min-height: 600px;
|
||||
border: 1px solid #383838;
|
||||
border-radius: 0 70px 70px 0;
|
||||
background:
|
||||
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.1), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
|
||||
#383838;
|
||||
box-shadow: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
transition: width 220ms ease, border-radius 220ms ease;
|
||||
}
|
||||
|
||||
.skg-board-rail.is-open .skg-board-rail__strip {
|
||||
width: 397px;
|
||||
border-radius: 0 34px 34px 0;
|
||||
}
|
||||
|
||||
.skg-board-rail__iconbar {
|
||||
width: 65px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skg-board-rail__logo {
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background:
|
||||
radial-gradient(circle at 68% 38%, #a2c638 0 34%, transparent 36%),
|
||||
radial-gradient(circle at 50% 50%, #c8cd19 0 47%, transparent 49%),
|
||||
#ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skg-board-rail__button {
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
transition: color 180ms ease, background 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.skg-board-rail__button:hover,
|
||||
.skg-board-rail__button:focus-visible,
|
||||
.skg-board-rail__button.is-active {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.skg-board-rail__drawer {
|
||||
height: 100%;
|
||||
animation: skgRailDrawerIn 220ms ease both;
|
||||
}
|
||||
|
||||
.skg-board-rail__drawer .skg-board-panel {
|
||||
height: 100%;
|
||||
min-height: 0 !important;
|
||||
border-radius: 0 30px 30px 0;
|
||||
}
|
||||
|
||||
@keyframes skgRailDrawerIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.skg-glass-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(circle at 80% 86%, rgba(162, 198, 56, 0.13), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.034)),
|
||||
rgba(38, 38, 38, 0.76);
|
||||
box-shadow: var(--skg-shadow-card);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.skg-glass-card--flat {
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.022)),
|
||||
rgba(18, 18, 18, 0.62);
|
||||
}
|
||||
|
||||
.skg-audio-waveform {
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
background: var(--skg-wave-bg) !important;
|
||||
}
|
||||
|
||||
.skg-audio-waveform__center {
|
||||
background: var(--skg-wave-grid);
|
||||
}
|
||||
|
||||
.skg-audio-waveform__segment {
|
||||
background: var(--skg-wave-marker);
|
||||
}
|
||||
|
||||
.skg-audio-waveform__hover {
|
||||
background: var(--skg-wave-hover);
|
||||
}
|
||||
|
||||
.skg-audio-waveform__playhead {
|
||||
background: var(--skg-wave-playhead);
|
||||
box-shadow: 0 0 16px var(--skg-wave-playhead-shadow);
|
||||
}
|
||||
|
||||
.skg-status-orb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 78% 32%, #a2c638 0 12%, transparent 13%),
|
||||
conic-gradient(from 40deg, #a2c638 0 74%, rgba(255, 255, 255, 0.22) 75% 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skg-board-theme--light {
|
||||
--skg-bg-1: #f4f1e8;
|
||||
--skg-bg-2: #fbfaf5;
|
||||
--skg-bg-3: rgba(0, 0, 0, 0.03);
|
||||
--skg-border: rgba(72, 78, 56, 0.14);
|
||||
--skg-text-1: #20241b;
|
||||
--skg-text-2: rgba(32, 36, 27, 0.68);
|
||||
--skg-text-3: rgba(32, 36, 27, 0.42);
|
||||
--skg-success: #059669;
|
||||
--skg-warn: #b7791f;
|
||||
--skg-danger: #e11d48;
|
||||
--skg-info: #0891b2;
|
||||
--skg-wave-bg: rgba(255, 255, 255, 0.74);
|
||||
--skg-wave-fill: rgba(80, 90, 70, 0.42);
|
||||
--skg-wave-stroke-1: rgba(47, 57, 44, 0.46);
|
||||
--skg-wave-stroke-2: rgba(47, 57, 44, 0.3);
|
||||
--skg-wave-grid: rgba(72, 78, 56, 0.16);
|
||||
--skg-wave-marker: rgba(72, 78, 56, 0.14);
|
||||
--skg-wave-hover: rgba(23, 96, 111, 0.52);
|
||||
--skg-wave-playhead: #10b981;
|
||||
--skg-wave-playhead-shadow: rgba(16, 185, 129, 0.36);
|
||||
color: var(--skg-text-1);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.16), transparent 31%),
|
||||
radial-gradient(circle at 4% 100%, rgba(143, 176, 113, 0.1), transparent 28%),
|
||||
linear-gradient(126deg, #f5f2e9 0%, #ece7dc 48%, #fbfaf5 100%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light::before {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(42, 50, 36, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(42, 50, 36, 0.045) 1px, transparent 1px);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.skg-board-theme--light::after {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.36), transparent 46%, rgba(214, 179, 106, 0.08)),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.3), transparent 42%, rgba(255, 255, 255, 0.24));
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-ambient {
|
||||
background:
|
||||
radial-gradient(circle at 20% 18%, rgba(214, 179, 106, 0.2), transparent 28%),
|
||||
radial-gradient(circle at 70% 6%, rgba(143, 176, 113, 0.16), transparent 30%),
|
||||
radial-gradient(circle at 52% 100%, rgba(214, 179, 106, 0.12), transparent 38%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-topbar,
|
||||
.skg-board-theme--light .skg-board-panel {
|
||||
border-color: rgba(82, 93, 62, 0.16) !important;
|
||||
background:
|
||||
radial-gradient(circle at 88% 18%, rgba(143, 176, 113, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(249, 247, 239, 0.64)),
|
||||
rgba(246, 243, 234, 0.84) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.78),
|
||||
0 18px 48px rgba(65, 55, 30, 0.12);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-topbar {
|
||||
background:
|
||||
linear-gradient(100deg, rgba(214, 179, 106, 0.1), rgba(143, 176, 113, 0.07) 42%, rgba(255, 255, 255, 0.72)),
|
||||
rgba(250, 248, 241, 0.9) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-shell {
|
||||
border-color: rgba(72, 78, 56, 0.14);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 243, 235, 0.62)),
|
||||
rgba(246, 243, 235, 0.74);
|
||||
box-shadow: 0 24px 70px rgba(74, 68, 44, 0.13);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-rail__strip {
|
||||
border-color: #3f4239;
|
||||
background:
|
||||
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
|
||||
#3f4239;
|
||||
box-shadow: 10px 10px 26px rgba(74, 68, 44, 0.18);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-glass-card {
|
||||
border-color: rgba(72, 78, 56, 0.16);
|
||||
background:
|
||||
radial-gradient(circle at 80% 86%, rgba(143, 176, 113, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(246, 243, 235, 0.64)),
|
||||
rgba(250, 248, 241, 0.82);
|
||||
box-shadow: 0 16px 42px rgba(74, 68, 44, 0.12);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-glass-card--flat {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 243, 235, 0.5)),
|
||||
rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-theme-toggle {
|
||||
border-color: rgba(82, 93, 62, 0.16) !important;
|
||||
background: rgba(255, 255, 255, 0.54) !important;
|
||||
color: rgba(36, 40, 30, 0.72) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .text-white,
|
||||
.skg-board-theme--light [class*="text-white/"] {
|
||||
color: rgba(32, 36, 28, 0.78) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="bg-black/"],
|
||||
.skg-board-theme--light [class*="bg-white/"] {
|
||||
background-color: rgba(255, 255, 250, 0.52) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="border-white/"] {
|
||||
border-color: rgba(70, 78, 54, 0.14) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="text-[#d7efbc]"] {
|
||||
color: #43662d !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="text-[#e8c77a]"],
|
||||
.skg-board-theme--light [class*="text-[#f2d58a]"],
|
||||
.skg-board-theme--light [class*="text-[#f5d98e]"] {
|
||||
color: #856015 !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="text-emerald-"] {
|
||||
color: #2f6d3d !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="text-cyan-"],
|
||||
.skg-board-theme--light [class*="text-sky-"],
|
||||
.skg-board-theme--light [class*="text-teal-"] {
|
||||
color: #17606f !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="text-amber-"],
|
||||
.skg-board-theme--light [class*="text-yellow-"] {
|
||||
color: #8a5c00 !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="text-rose-"],
|
||||
.skg-board-theme--light [class*="text-red-"] {
|
||||
color: #9f1239 !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="text-violet-"],
|
||||
.skg-board-theme--light [class*="text-purple-"] {
|
||||
color: #62438a !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="border-[#8fb071]"] {
|
||||
border-color: rgba(67, 102, 45, 0.28) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="border-[#d6b36a]"] {
|
||||
border-color: rgba(133, 96, 21, 0.26) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light [class*="bg-[#8fb071]"],
|
||||
.skg-board-theme--light [class*="bg-[#d6b36a]"] {
|
||||
background-color: rgba(214, 179, 106, 0.14) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light input,
|
||||
.skg-board-theme--light textarea,
|
||||
.skg-board-theme--light select {
|
||||
color: #22261f !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light input::placeholder,
|
||||
.skg-board-theme--light textarea::placeholder {
|
||||
color: rgba(34, 38, 31, 0.36) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light ::selection {
|
||||
background: rgba(214, 179, 106, 0.32);
|
||||
color: #171a14;
|
||||
}
|
||||
|
||||
.skg-board-brand {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.skg-board-brand__logo-chip {
|
||||
display: inline-flex;
|
||||
height: 42px;
|
||||
width: 132px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(214, 179, 106, 0.24);
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: #f5efe3;
|
||||
box-shadow: var(--skg-shadow-button);
|
||||
}
|
||||
|
||||
.skg-board-brand__logo {
|
||||
width: 96px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skg-board-brand__system {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--skg-gold-1);
|
||||
}
|
||||
|
||||
.skg-board-brand__title {
|
||||
margin-top: 3px;
|
||||
color: var(--skg-text-1);
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.skg-board-brand__subtitle {
|
||||
margin-top: 4px;
|
||||
color: var(--skg-text-3);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.skg-stat-card {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 92% 86%, rgba(162, 198, 56, 0.22), transparent 48%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background:
|
||||
var(--skg-stat-glow),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.045)),
|
||||
rgba(0, 0, 0, 0.16);
|
||||
color: #ffffff;
|
||||
box-shadow: 8px 8px 10px rgba(0, 0, 0, 0.22);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.skg-stat-card--violet {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 82%, rgba(126, 87, 194, 0.74), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card--lime {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.72), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card--gold {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.7), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card--teal {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.7), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card--green {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.72), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card__label {
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
|
||||
.skg-stat-card__value {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skg-primary-action {
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: linear-gradient(135deg, #c8cd19, #a2c638);
|
||||
color: #101010;
|
||||
box-shadow: var(--skg-shadow-button);
|
||||
}
|
||||
|
||||
.skg-primary-action:hover {
|
||||
background: linear-gradient(135deg, #d6db25, #b0d83d);
|
||||
}
|
||||
|
||||
.skg-secondary-action {
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.skg-secondary-action:hover {
|
||||
border-color: rgba(162, 198, 56, 0.44);
|
||||
background: rgba(162, 198, 56, 0.11);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skg-empty-state {
|
||||
border: 1px dashed rgba(214, 179, 106, 0.22);
|
||||
border-radius: var(--skg-radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(214, 179, 106, 0.1), transparent 38%),
|
||||
rgba(255, 255, 255, 0.028);
|
||||
color: var(--skg-text-3);
|
||||
}
|
||||
|
||||
.skg-empty-character {
|
||||
width: min(230px, 82%);
|
||||
margin: 0 auto 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skg-empty-character .login-character-stage {
|
||||
min-height: 112px;
|
||||
border-color: rgba(214, 179, 106, 0.16);
|
||||
background:
|
||||
radial-gradient(circle at 78% 18%, rgba(214, 179, 106, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.026));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.skg-empty-character .login-character-stage::after,
|
||||
.skg-empty-character .login-stage-grid {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skg-empty-character .login-characters-container {
|
||||
bottom: -6px;
|
||||
transform: translateX(-50%) scale(0.22);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-brand__logo-chip {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(143, 176, 113, 0.2), transparent 50%);
|
||||
border-color: rgba(72, 78, 56, 0.14);
|
||||
background:
|
||||
var(--skg-stat-glow),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(246, 243, 235, 0.58)),
|
||||
rgba(255, 255, 255, 0.56);
|
||||
color: #20241b;
|
||||
box-shadow: 0 12px 28px rgba(74, 68, 44, 0.1);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--violet {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(126, 87, 194, 0.22), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--lime {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.28), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--gold {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.26), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--teal {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.24), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--green {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.24), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card__label {
|
||||
color: rgba(32, 36, 27, 0.48);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card__value {
|
||||
color: #20241b;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-primary-action {
|
||||
background: linear-gradient(135deg, #c8cd19, #a2c638);
|
||||
color: #10140d;
|
||||
box-shadow: 0 12px 28px rgba(128, 144, 37, 0.2);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-primary-action:hover {
|
||||
background: linear-gradient(135deg, #d6db25, #b0d83d);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-secondary-action {
|
||||
border-color: rgba(72, 78, 56, 0.16);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(32, 36, 27, 0.72);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-secondary-action:hover {
|
||||
border-color: rgba(143, 176, 113, 0.38);
|
||||
background: rgba(143, 176, 113, 0.12);
|
||||
color: #20241b;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-empty-state {
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.28), transparent 38%),
|
||||
rgba(255, 255, 255, 0.66);
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
isolation: isolate;
|
||||
color: #282828;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import {
|
||||
ReactFlow, Background, BackgroundVariant, Controls,
|
||||
useNodesState, useEdgesState,
|
||||
@@ -14,11 +13,11 @@ import {
|
||||
type CanvasPanelDock,
|
||||
type NodeData,
|
||||
} from "@/components/nodes"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { AdRecreationBoard } from "@/components/ad-recreation-board"
|
||||
import {
|
||||
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
||||
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset,
|
||||
formatJobError, retryJobDownload,
|
||||
type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
|
||||
} from "@/lib/api"
|
||||
import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target"
|
||||
@@ -61,6 +60,11 @@ const DEFAULT_PRODUCT_LIBRARY_IDS = [
|
||||
]
|
||||
const VIDEO_READY_STATUSES: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
|
||||
|
||||
function isAudioProcessing(job?: Job | null) {
|
||||
if (!job) return false
|
||||
return job.audio_script?.status === "rewriting" || (job.status === "transcribing" && job.audio_script?.status !== "failed")
|
||||
}
|
||||
|
||||
const PRODUCT_FUSION_WEARING_PROMPT = [
|
||||
"Product placement must be physically correct:",
|
||||
"The SKG device is a rigid opaque white U-shaped neck massager, not a soft scarf, necklace, cable, collar, sticker, implant, or transparent body part.",
|
||||
@@ -144,7 +148,6 @@ const EDGES_RAW: Array<[string, string]> = [
|
||||
]
|
||||
|
||||
export default function Home() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||
const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
|
||||
@@ -288,8 +291,10 @@ export default function Home() {
|
||||
updateJobInList(updated)
|
||||
setActiveJobId((prev) => prev ?? updated.id)
|
||||
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`)
|
||||
return updated
|
||||
} catch (e) {
|
||||
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
return undefined
|
||||
}
|
||||
}, [updateJobInList])
|
||||
|
||||
@@ -450,7 +455,7 @@ export default function Home() {
|
||||
if (!options?.silent) toast.info("视频导入完成后,可在音频卡片点击提取音频")
|
||||
return
|
||||
}
|
||||
if (target.status === "transcribing" || target.audio_script?.status === "rewriting") {
|
||||
if (isAudioProcessing(target)) {
|
||||
if (!options?.silent) toast.info("音频正在处理中")
|
||||
return
|
||||
}
|
||||
@@ -468,8 +473,9 @@ export default function Home() {
|
||||
if (!videoReady) return
|
||||
|
||||
const audioKey = `${target.id}:audio`
|
||||
const hasAudioResult = !!target.audio_script?.source_text || target.transcript.length > 0
|
||||
const audioRunning = target.status === "transcribing" || target.audio_script?.status === "rewriting"
|
||||
const audioFailed = target.audio_script?.status === "failed"
|
||||
const hasAudioResult = !audioFailed && (!!target.audio_script?.source_text || target.transcript.length > 0)
|
||||
const audioRunning = isAudioProcessing(target)
|
||||
if (!hasAudioResult && !audioRunning && !autoTriggeredRef.current.has(audioKey)) {
|
||||
autoTriggeredRef.current.add(audioKey)
|
||||
try {
|
||||
@@ -572,15 +578,30 @@ export default function Home() {
|
||||
const handleStartProduction = useCallback(async (inputUrl?: string) => {
|
||||
const trimmed = inputUrl?.trim()
|
||||
const created = trimmed ? await handleSubmit(trimmed) : undefined
|
||||
const target = created ?? job
|
||||
let target = created ?? job
|
||||
if (!target) {
|
||||
toast.info("先粘贴视频链接或选择一个素材任务")
|
||||
return
|
||||
}
|
||||
if (!created && target.status === "failed") {
|
||||
autoTriggeredRef.current.delete(`${target.id}:audio`)
|
||||
autoTriggeredRef.current.delete(`${target.id}:visual`)
|
||||
}
|
||||
if (!created && target.status === "failed" && !target.video_url) {
|
||||
try {
|
||||
target = await retryJobDownload(target.id)
|
||||
updateJobInList(target)
|
||||
toast.info("已重新提交下载;下载完成后会自动跑音频文案路和视觉抽帧路")
|
||||
} catch (e) {
|
||||
toast.error("重新下载失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
return
|
||||
}
|
||||
}
|
||||
setProductionJobIds((prev) => new Set(prev).add(target.id))
|
||||
toast.success("已进入并行素材分析:下载完成后自动跑音频文案路和视觉抽帧路")
|
||||
if (target.video_url) toast.success("已进入并行素材分析:音频文案路和视觉抽帧路会同步推进")
|
||||
else toast.success("已进入并行素材分析:下载完成后自动跑音频文案路和视觉抽帧路")
|
||||
void startProductionLanesForJob(target)
|
||||
}, [handleSubmit, job, startProductionLanesForJob])
|
||||
}, [handleSubmit, job, startProductionLanesForJob, updateJobInList])
|
||||
|
||||
useEffect(() => {
|
||||
if (productionJobIds.size === 0) return
|
||||
@@ -863,6 +884,9 @@ export default function Home() {
|
||||
if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") {
|
||||
toast.info("视频已下载,音频解析会自动开始;也可以在右侧手动重试", { duration: 6000 })
|
||||
}
|
||||
if (job?.status === "failed" && prevStatusRef.current !== "failed") {
|
||||
toast.error(formatJobError(job.error) || "任务失败", { duration: 10000 })
|
||||
}
|
||||
prevStatusRef.current = job?.status ?? null
|
||||
|
||||
const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
|
||||
@@ -870,7 +894,12 @@ export default function Home() {
|
||||
.filter((item) => {
|
||||
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||
const runningAudio = item.audio_script?.status === "rewriting"
|
||||
return runningVideo || runningAudio || !TERMINAL.includes(item.status)
|
||||
const runningSubject = item.frames.some((frame) =>
|
||||
frame.elements?.some((element) =>
|
||||
element.subject_assets?.some((asset) => asset.status === "queued" || asset.status === "in_progress"),
|
||||
),
|
||||
)
|
||||
return runningVideo || runningAudio || runningSubject || !TERMINAL.includes(item.status)
|
||||
})
|
||||
.map((item) => item.id)
|
||||
|
||||
@@ -891,7 +920,14 @@ export default function Home() {
|
||||
}, [
|
||||
job?.id,
|
||||
job?.status,
|
||||
jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
|
||||
jobs.map((item) => {
|
||||
const subjectState = item.frames.flatMap((frame) =>
|
||||
frame.elements?.flatMap((element) =>
|
||||
element.subject_assets?.map((asset) => `${asset.id}:${asset.status ?? "completed"}:${asset.progress ?? 100}:${asset.url ?? ""}`) ?? [],
|
||||
) ?? [],
|
||||
).join(",")
|
||||
return `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}:${subjectState}`
|
||||
}).join("|"),
|
||||
])
|
||||
|
||||
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
|
||||
@@ -1199,9 +1235,6 @@ export default function Home() {
|
||||
<div className="canvas-bg" />
|
||||
<main className="relative flex h-screen w-screen overflow-hidden">
|
||||
<AdRecreationBoard data={nodeData} onGenerateVideo={handleQuickGenerateVideo} />
|
||||
<div className="absolute bottom-4 right-4 z-30 pointer-events-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Toaster theme="system" position="top-center" />
|
||||
</main>
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -319,7 +319,7 @@ export function AudioStrip({ job, open, onClose }: { job: Job | null; open: bool
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-white/12 text-[12px] text-white/45">
|
||||
点击音频卡片后开始解析;完成后这里会按时间显示英文、中文翻译和对应波形。
|
||||
点击音频卡片后开始解析;完成后这里会按时间显示原语言文案、中文镜像和对应波形。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -514,7 +514,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
)}
|
||||
{!hasFrames ? (
|
||||
<KanbanCard tone="pink" tags={["分镜"]} title="等待解析后抽取">
|
||||
<div className="text-[11.5px] text-[var(--text-soft)]">候选 30 张 → pHash 去重 + 清晰度排序 → 时序分桶 → 5 张代表分镜</div>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)]">候选帧 → pHash 去重 + 清晰度排序 + 时序分桶 → 按当前设置产出参考帧</div>
|
||||
</KanbanCard>
|
||||
) : (
|
||||
job!.frames.map((f) => {
|
||||
@@ -641,15 +641,15 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
</div>
|
||||
</KanbanCard>
|
||||
|
||||
<KanbanCard tone="green" tags={["配音"]} title={job?.audio_script?.voice_model || "MiniMax T2A"}>
|
||||
<KanbanCard tone="green" tags={["配音"]} title={job?.audio_script?.voice_model || "Azure OpenAI TTS"}>
|
||||
{job?.audio_script?.voice_url ? (
|
||||
<audio controls className="h-8 w-full" src={apiAssetUrl(job.audio_script.voice_url)} />
|
||||
) : (
|
||||
<div className="text-[11px] text-[var(--text-soft)]">
|
||||
{job?.audio_script?.error || "配置 MiniMax 后自动生成配音文件"}
|
||||
{job?.audio_script?.error || "当前第一步不默认生成配音文件;后续新配音阶段走 Azure OpenAI TTS"}
|
||||
</div>
|
||||
)}
|
||||
<div className="kanban-meta">{job?.audio_script?.voice_id || "random English voice"}</div>
|
||||
<div className="kanban-meta">{job?.audio_script?.voice_id || "Azure voice"}</div>
|
||||
</KanbanCard>
|
||||
</>
|
||||
)}
|
||||
@@ -673,7 +673,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
{key === "videogen" && (
|
||||
<>
|
||||
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Seedance / Kling / Veo 3">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">通过 /v1/videos 网关提交,模型 ID 走环境变量映射</div>
|
||||
<div className="text-[11px] text-[var(--text-soft)]">按后端 VIDEO_CREATE_PATHS 提交,模型 ID 走环境变量映射</div>
|
||||
</KanbanCard>
|
||||
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">字节跳动 · 需独立 API key</div>
|
||||
|
||||
@@ -14,6 +14,8 @@ type MediaAssetAction = {
|
||||
tone?: "neutral" | "cyan" | "rose"
|
||||
}
|
||||
|
||||
type MediaAssetPreviewPlacement = "auto" | "left" | "right"
|
||||
|
||||
type MediaAssetTileProps = {
|
||||
kind?: "image" | "video"
|
||||
src?: string
|
||||
@@ -29,6 +31,8 @@ type MediaAssetTileProps = {
|
||||
objectFit?: "contain" | "cover"
|
||||
previewObjectFit?: "contain" | "cover"
|
||||
previewClassName?: string
|
||||
previewPlacement?: MediaAssetPreviewPlacement
|
||||
previewMaxWidth?: number
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
busy?: boolean
|
||||
@@ -42,6 +46,7 @@ type MediaAssetTileProps = {
|
||||
deleting?: boolean
|
||||
deleteDisabled?: boolean
|
||||
actions?: MediaAssetAction[]
|
||||
actionsAlwaysVisible?: boolean
|
||||
disablePreview?: boolean
|
||||
}
|
||||
|
||||
@@ -55,15 +60,21 @@ function mediaObjectClass(fit: "contain" | "cover") {
|
||||
return fit === "cover" ? "object-cover" : "object-contain"
|
||||
}
|
||||
|
||||
function previewPosition(event: ReactMouseEvent<HTMLElement>) {
|
||||
function previewPosition(event: ReactMouseEvent<HTMLElement>, placement: MediaAssetPreviewPlacement, maxWidth: number) {
|
||||
const margin = 16
|
||||
const previewWidth = Math.min(520, window.innerWidth - margin * 2)
|
||||
const previewWidth = Math.min(maxWidth, window.innerWidth - margin * 2)
|
||||
const previewHeight = Math.min(760, window.innerHeight - margin * 2)
|
||||
let left = event.clientX + 18
|
||||
let left = placement === "left" ? event.clientX - previewWidth - 18 : event.clientX + 18
|
||||
let top = event.clientY + 18
|
||||
if (left + previewWidth > window.innerWidth - margin) left = event.clientX - previewWidth - 18
|
||||
if (placement === "auto" && left + previewWidth > window.innerWidth - margin) left = event.clientX - previewWidth - 18
|
||||
if (placement === "right" && left + previewWidth > window.innerWidth - margin) left = window.innerWidth - previewWidth - margin
|
||||
if (placement === "left" && left < margin) left = margin
|
||||
if (top + previewHeight > window.innerHeight - margin) top = window.innerHeight - previewHeight - margin
|
||||
return { left: Math.max(margin, left), top: Math.max(margin, top) }
|
||||
return {
|
||||
left: Math.max(margin, Math.min(left, window.innerWidth - previewWidth - margin)),
|
||||
top: Math.max(margin, top),
|
||||
width: previewWidth,
|
||||
}
|
||||
}
|
||||
|
||||
export function MediaAssetTile({
|
||||
@@ -81,6 +92,8 @@ export function MediaAssetTile({
|
||||
objectFit = "contain",
|
||||
previewObjectFit,
|
||||
previewClassName = "",
|
||||
previewPlacement = "auto",
|
||||
previewMaxWidth = 520,
|
||||
selected = false,
|
||||
disabled = false,
|
||||
busy = false,
|
||||
@@ -94,9 +107,10 @@ export function MediaAssetTile({
|
||||
deleting = false,
|
||||
deleteDisabled = false,
|
||||
actions = [],
|
||||
actionsAlwaysVisible = false,
|
||||
disablePreview = false,
|
||||
}: MediaAssetTileProps) {
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
|
||||
const mediaSrc = src || poster || ""
|
||||
const canPreview = !!mediaSrc && !disablePreview
|
||||
const fit = mediaObjectClass(objectFit)
|
||||
@@ -104,7 +118,7 @@ export function MediaAssetTile({
|
||||
|
||||
const updatePreview = (event: ReactMouseEvent<HTMLElement>) => {
|
||||
if (!canPreview) return
|
||||
setPosition(previewPosition(event))
|
||||
setPosition(previewPosition(event, previewPlacement, previewMaxWidth))
|
||||
}
|
||||
|
||||
const media = kind === "video" && src ? (
|
||||
@@ -136,7 +150,7 @@ export function MediaAssetTile({
|
||||
? createPortal(
|
||||
<div
|
||||
className={`pointer-events-none fixed z-[10000] w-[min(520px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/94 p-3 shadow-[0_28px_80px_rgba(0,0,0,0.72)] ${previewClassName}`}
|
||||
style={{ left: position.left, top: position.top }}
|
||||
style={{ left: position.left, top: position.top, width: position.width }}
|
||||
>
|
||||
<div className="flex max-h-[min(76vh,720px)] items-center justify-center overflow-hidden rounded-lg bg-black">
|
||||
{kind === "video" && src ? (
|
||||
@@ -188,7 +202,7 @@ export function MediaAssetTile({
|
||||
{topRight ? <div className="pointer-events-none absolute right-1 top-1 z-10">{topRight}</div> : null}
|
||||
{bottom ? <div className="pointer-events-none absolute bottom-1 left-1 right-1 z-10">{bottom}</div> : null}
|
||||
{(actions.length || onDelete) ? (
|
||||
<div className="absolute right-1 top-1 z-20 flex flex-col gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<div className={`absolute right-1 top-1 z-20 flex flex-col gap-0.5 transition ${actionsAlwaysVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"}`}>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface NodeData {
|
||||
onFramePanelDockChange?: (dock: CanvasPanelDock) => void
|
||||
onCloseExpandedFrame: () => void
|
||||
onAddManualFrame: (t: number) => void
|
||||
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<void> | void
|
||||
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<Job | void> | Job | void
|
||||
onOpenVideoPanel?: (jobId: string) => void
|
||||
onCloseVideoPanel?: () => void
|
||||
onVideoPanelScaleChange?: (scale: number) => void
|
||||
@@ -2017,7 +2017,7 @@ export function ASRNode({ data, selected }: any) {
|
||||
onTogglePin={() => d.onToggleNodePin?.("asr")}
|
||||
>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)]">
|
||||
OpenAI-compatible ASR · 英文带时间戳分段
|
||||
OpenAI-compatible ASR · 原语言带时间戳分段
|
||||
</div>
|
||||
{d.job && d.job.transcript.length > 0 && (
|
||||
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
|
||||
@@ -2102,7 +2102,7 @@ export function RewriteNode({ data, selected }: any) {
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
5b. AudioNode — 合并 ASR + 翻译 + 改写 + MiniMax 配音
|
||||
5b. AudioNode — 合并 ASR + 翻译 + 改写 + Azure OpenAI 配音
|
||||
============================================================ */
|
||||
export function AudioNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
@@ -2152,9 +2152,9 @@ export function AudioNode({ data, selected }: any) {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
音轨 → 取时长/节奏 → SKG 英文产品口播 → MiniMax 随机英文配音<br />
|
||||
音轨 → 取时长/节奏 → SKG 英文产品口播 → Azure OpenAI 英文配音<br />
|
||||
<span className="text-[var(--text-faint)] font-mono">
|
||||
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "MiniMax T2A"}
|
||||
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "Azure OpenAI TTS"}
|
||||
</span>
|
||||
</div>
|
||||
{job && (
|
||||
@@ -2195,7 +2195,7 @@ export function AudioNode({ data, selected }: any) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{voiceUrl && <div className="text-[10.5px] text-emerald-200/85">MiniMax natural English voice ready · 底部音频条播放</div>}
|
||||
{voiceUrl && <div className="text-[10.5px] text-emerald-200/85">Azure OpenAI English voice ready · 底部音频条播放</div>}
|
||||
{isRewriting && (
|
||||
<div className="text-[10.5px] text-[var(--text-faint)]">正在按原音频时长生成英文产品口播和配音…</div>
|
||||
)}
|
||||
|
||||
678
web/components/resource-library/library-drawer.tsx
Normal file
678
web/components/resource-library/library-drawer.tsx
Normal file
@@ -0,0 +1,678 @@
|
||||
"use client"
|
||||
|
||||
import { type MouseEvent as ReactMouseEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import {
|
||||
BookOpen, Check, Copy, Database, Download, Edit3, FileText, Image as ImageIcon, Loader2,
|
||||
Package, Plus, Search, Sparkles, Trash2, Upload, Video, X,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { MediaAssetTile } from "@/components/media-asset-tile"
|
||||
import {
|
||||
type AssetLibraryItem,
|
||||
type AssetLibraryKind,
|
||||
type ImageRef,
|
||||
type PromptLibraryCategory,
|
||||
type PromptLibraryItem,
|
||||
type ResourceLibraryRecentItem,
|
||||
apiAssetUrl,
|
||||
copyAssetLibraryToJob,
|
||||
createAssetLibraryItem,
|
||||
createPromptLibraryItem,
|
||||
deleteAssetLibraryItem,
|
||||
deletePromptLibraryItem,
|
||||
getAssetLibraryRefs,
|
||||
getResourceLibraryRecent,
|
||||
listAssetLibrary,
|
||||
listPromptLibrary,
|
||||
usePromptLibraryItem,
|
||||
} from "@/lib/api"
|
||||
|
||||
type LibraryTab = "prompts" | "assets"
|
||||
type LibraryApplyTarget = "copy_only" | "product_pool"
|
||||
|
||||
type LibraryDrawerProps = {
|
||||
open: boolean
|
||||
currentJobId?: string
|
||||
onClose: () => void
|
||||
onApplyAsset?: (kind: AssetLibraryKind, ref: ImageRef, target: LibraryApplyTarget, item: AssetLibraryItem) => Promise<void> | void
|
||||
}
|
||||
|
||||
const DRAWER_STORAGE_KEY = "skg-resource-library-drawer"
|
||||
const PROMPT_COLUMNS: Array<{ category: PromptLibraryCategory; label: string; desc: string }> = [
|
||||
{ category: "scene_desc", label: "场景描述", desc: "首尾帧、场景图、环境描述" },
|
||||
{ category: "video_desc", label: "视频描述", desc: "视频生成动作、镜头语言" },
|
||||
{ category: "subject_desc", label: "主体描述", desc: "人物、透明骨架、角色 brief" },
|
||||
{ category: "skg_script", label: "SKG 文案", desc: "口播、卖点、作者意图" },
|
||||
{ category: "product_angle", label: "产品角度", desc: "视角、佩戴、结构约束" },
|
||||
]
|
||||
const ASSET_COLUMNS: Array<{ kind: AssetLibraryKind; label: string; icon: ReactNode }> = [
|
||||
{ kind: "subjects", label: "主体", icon: <Sparkles className="h-3.5 w-3.5" /> },
|
||||
{ kind: "products", label: "产品", icon: <Package className="h-3.5 w-3.5" /> },
|
||||
{ kind: "scenes", label: "场景", icon: <ImageIcon className="h-3.5 w-3.5" /> },
|
||||
{ kind: "videos", label: "视频", icon: <Video className="h-3.5 w-3.5" /> },
|
||||
]
|
||||
|
||||
function cn(...items: Array<string | false | null | undefined>) {
|
||||
return items.filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
function formatAgo(ts?: number) {
|
||||
if (!ts) return "-"
|
||||
const diff = Math.max(0, Date.now() / 1000 - ts)
|
||||
if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))} 分钟前`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
|
||||
return `${Math.floor(diff / 86400)} 天前`
|
||||
}
|
||||
|
||||
function monthLabel(ts?: number) {
|
||||
const date = ts ? new Date(ts * 1000) : new Date()
|
||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月`
|
||||
}
|
||||
|
||||
function matchesPrompt(item: PromptLibraryItem, q: string) {
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return true
|
||||
return [item.name, item.prompt_en, item.prompt_zh, item.tags.join(" ")].join(" ").toLowerCase().includes(needle)
|
||||
}
|
||||
|
||||
function matchesAsset(item: AssetLibraryItem, q: string) {
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return true
|
||||
return [item.name, item.name_zh, item.note, item.prompt_brief, item.prompt_brief_zh, item.tags.join(" ")].join(" ").toLowerCase().includes(needle)
|
||||
}
|
||||
|
||||
function groupByMonth<T extends { created_at?: number }>(items: T[]) {
|
||||
const groups = new Map<string, T[]>()
|
||||
for (const item of [...items].sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))) {
|
||||
const label = monthLabel(item.created_at)
|
||||
groups.set(label, [...(groups.get(label) ?? []), item])
|
||||
}
|
||||
return [...groups.entries()]
|
||||
}
|
||||
|
||||
function assetThumb(item: AssetLibraryItem) {
|
||||
const image = item.image || item.poster || item.views?.[0] || item.images?.[0]
|
||||
if (image?.url) return apiAssetUrl(image.url)
|
||||
if (item.video_url) return apiAssetUrl(item.video_url)
|
||||
return ""
|
||||
}
|
||||
|
||||
function assetMeta(item: AssetLibraryItem) {
|
||||
if (item.kind === "subjects") return `${item.images?.length || item.views?.length || 0} 图 · ${item.subject_style === "source_actor" ? "真人" : "骨架"}`
|
||||
if (item.kind === "products") return `${item.views?.length || 0} 视角 · ${item.product_type || "肩颈产品"}`
|
||||
if (item.kind === "scenes") return item.asset_role || item.aspect_ratio || "场景图"
|
||||
return item.duration ? `${item.duration.toFixed(1)}s` : "视频素材"
|
||||
}
|
||||
|
||||
export function LibraryDrawer({ open, currentJobId, onClose, onApplyAsset }: LibraryDrawerProps) {
|
||||
const [tab, setTab] = useState<LibraryTab>("prompts")
|
||||
const [search, setSearch] = useState("")
|
||||
const [prompts, setPrompts] = useState<PromptLibraryItem[]>([])
|
||||
const [assets, setAssets] = useState<Record<AssetLibraryKind, AssetLibraryItem[]>>({ subjects: [], products: [], scenes: [], videos: [] })
|
||||
const [recent, setRecent] = useState<ResourceLibraryRecentItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [newPromptOpen, setNewPromptOpen] = useState(false)
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<PromptLibraryItem | AssetLibraryItem | null>(null)
|
||||
const [pulseId, setPulseId] = useState<string>("")
|
||||
const [rect, setRect] = useState({ width: 1100, height: 700, left: 0, top: 0 })
|
||||
const dragRef = useRef<{ mode: "move" | "resize"; x: number; y: number; rect: typeof rect } | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [promptItems, subjects, products, scenes, videos, recentItems] = await Promise.all([
|
||||
listPromptLibrary(),
|
||||
listAssetLibrary("subjects"),
|
||||
listAssetLibrary("products"),
|
||||
listAssetLibrary("scenes"),
|
||||
listAssetLibrary("videos"),
|
||||
getResourceLibraryRecent(24),
|
||||
])
|
||||
setPrompts(promptItems)
|
||||
setAssets({ subjects, products, scenes, videos })
|
||||
setRecent(recentItems.items)
|
||||
} catch (error) {
|
||||
toast.error("资源库读取失败:" + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
try {
|
||||
const saved = window.localStorage.getItem(DRAWER_STORAGE_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved)
|
||||
setTab(parsed.tab === "assets" ? "assets" : "prompts")
|
||||
setRect({
|
||||
width: Math.max(800, Math.min(Number(parsed.width) || 1100, window.innerWidth - 32)),
|
||||
height: Math.max(540, Math.min(Number(parsed.height) || 700, window.innerHeight - 32)),
|
||||
left: Math.max(16, Math.min(Number(parsed.left) || (window.innerWidth - 1100) / 2, window.innerWidth - 240)),
|
||||
top: Math.max(16, Math.min(Number(parsed.top) || (window.innerHeight - 700) / 2, window.innerHeight - 120)),
|
||||
})
|
||||
} else {
|
||||
setRect({
|
||||
width: Math.min(1100, window.innerWidth - 32),
|
||||
height: Math.min(700, window.innerHeight - 32),
|
||||
left: Math.max(16, (window.innerWidth - Math.min(1100, window.innerWidth - 32)) / 2),
|
||||
top: Math.max(16, (window.innerHeight - Math.min(700, window.innerHeight - 32)) / 2),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// storage is optional
|
||||
}
|
||||
void refresh()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
try {
|
||||
window.localStorage.setItem(DRAWER_STORAGE_KEY, JSON.stringify({ ...rect, tab }))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [open, rect, tab])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const state = dragRef.current
|
||||
if (!state) return
|
||||
if (state.mode === "move") {
|
||||
setRect((current) => ({
|
||||
...current,
|
||||
left: Math.max(8, Math.min(state.rect.left + event.clientX - state.x, window.innerWidth - 240)),
|
||||
top: Math.max(8, Math.min(state.rect.top + event.clientY - state.y, window.innerHeight - 120)),
|
||||
}))
|
||||
} else {
|
||||
setRect((current) => ({
|
||||
...current,
|
||||
width: Math.max(800, Math.min(state.rect.width + event.clientX - state.x, window.innerWidth - current.left - 8)),
|
||||
height: Math.max(540, Math.min(state.rect.height + event.clientY - state.y, window.innerHeight - current.top - 8)),
|
||||
}))
|
||||
}
|
||||
}
|
||||
const onUp = () => { dragRef.current = null }
|
||||
window.addEventListener("mousemove", onMove)
|
||||
window.addEventListener("mouseup", onUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove)
|
||||
window.removeEventListener("mouseup", onUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyPrompt = async (item: PromptLibraryItem, mode: "en" | "zh" | "both" = "en") => {
|
||||
const text = mode === "zh" ? item.prompt_zh : mode === "both" ? `${item.prompt_en}\n\n中文:${item.prompt_zh}` : item.prompt_en
|
||||
await navigator.clipboard.writeText(text || item.prompt_en || item.prompt_zh)
|
||||
const updated = await usePromptLibraryItem(item.id)
|
||||
setPrompts((current) => current.map((candidate) => candidate.id === item.id ? updated : candidate))
|
||||
toast.success("已复制 · 可粘贴到任意输入框")
|
||||
}
|
||||
|
||||
const deletePrompt = async (item: PromptLibraryItem) => {
|
||||
if (!window.confirm(`删除提示词「${item.name}」?`)) return
|
||||
await deletePromptLibraryItem(item.id)
|
||||
setPrompts((current) => current.filter((candidate) => candidate.id !== item.id))
|
||||
toast.success("提示词已移入回收区")
|
||||
}
|
||||
|
||||
const applyAsset = async (item: AssetLibraryItem) => {
|
||||
if (!currentJobId) {
|
||||
toast.warning("先选择一个 job,再应用素材。")
|
||||
return
|
||||
}
|
||||
const target = item.kind === "products" && window.confirm("应用到产品素材池?取消则仅复制到当前 job 素材目录。") ? "product_pool" : "copy_only"
|
||||
const result = await copyAssetLibraryToJob(item.kind, item.id, currentJobId)
|
||||
if ("kind" in result && result.kind === "video") {
|
||||
await navigator.clipboard.writeText(result.url)
|
||||
toast.success("视频已复制到当前 job,链接已复制")
|
||||
return
|
||||
}
|
||||
await onApplyAsset?.(item.kind, result as ImageRef, target, item)
|
||||
await navigator.clipboard.writeText((result as ImageRef).element_id || "")
|
||||
toast.success(target === "product_pool" ? "已应用到产品素材池" : "已复制到当前 job,素材 ID 已复制")
|
||||
void refresh()
|
||||
}
|
||||
|
||||
const deleteAsset = async (item: AssetLibraryItem) => {
|
||||
const refs = await getAssetLibraryRefs(item.kind, item.id)
|
||||
if (refs.count && !window.confirm(`${refs.count} 个 job 仍在引用这个库素材,仍要删除?`)) return
|
||||
if (!refs.count && !window.confirm(`删除素材「${item.name}」?`)) return
|
||||
await deleteAssetLibraryItem(item.kind, item.id, refs.count > 0)
|
||||
setAssets((current) => ({ ...current, [item.kind]: current[item.kind].filter((candidate) => candidate.id !== item.id) }))
|
||||
toast.success("素材已移入回收区")
|
||||
}
|
||||
|
||||
const recentNodes = recent.slice(0, 12)
|
||||
|
||||
if (!open || typeof document === "undefined") return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9000] pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto fixed flex min-w-[800px] flex-col overflow-hidden rounded-xl border border-white/12 bg-[#070707]/96 text-white shadow-[0_28px_90px_rgba(0,0,0,0.72)] backdrop-blur-xl"
|
||||
style={{ width: rect.width, height: rect.height, left: rect.left, top: rect.top }}
|
||||
>
|
||||
<header
|
||||
className="flex cursor-move items-center justify-between gap-3 border-b border-white/10 bg-white/[0.035] px-3 py-2"
|
||||
onMouseDown={(event) => {
|
||||
if ((event.target as HTMLElement).closest("button,input")) return
|
||||
dragRef.current = { mode: "move", x: event.clientX, y: event.clientY, rect }
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-[#f5efe3] text-black shadow-xl shadow-black/25"><BookOpen className="h-4 w-4" /></span>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold">全局资源中心</div>
|
||||
<div className="text-[10px] text-white/42">提示词与素材只沉淀到库;应用到 job 时永远复制文件。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-md border border-white/10 bg-black/35 p-0.5">
|
||||
{[
|
||||
["prompts", "提示词库"],
|
||||
["assets", "素材库"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTab(value as LibraryTab)}
|
||||
className={cn("h-7 rounded px-2.5 text-[11px] font-semibold transition", tab === value ? "bg-[#f5efe3] text-black" : "text-white/52 hover:text-white")}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex h-8 min-w-[240px] items-center gap-1.5 rounded-md border border-white/10 bg-black/35 px-2 text-white/46">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前库,不隐藏上下文" className="w-full bg-transparent text-[11px] text-white/78 outline-none placeholder:text-white/28" />
|
||||
</label>
|
||||
<button type="button" onClick={() => tab === "prompts" ? setNewPromptOpen(true) : setUploadOpen(true)} className="skg-primary-action inline-flex h-8 items-center gap-1.5 px-2.5 text-[11px] font-semibold">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.04] text-white/58 hover:text-white">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<LibraryRecentStrip items={recentNodes} onPick={(item) => { setPulseId(item.id); setDetail(item.item); setTimeout(() => setPulseId(""), 2000) }} />
|
||||
|
||||
<main className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_260px]">
|
||||
<div className="min-w-0 overflow-x-auto p-3">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-white/45"><Loader2 className="mr-2 h-4 w-4 animate-spin" />资源库读取中</div>
|
||||
) : tab === "prompts" ? (
|
||||
<div className="grid h-full auto-cols-[260px] grid-flow-col gap-4">
|
||||
{PROMPT_COLUMNS.map((column) => (
|
||||
<PromptColumn
|
||||
key={column.category}
|
||||
label={column.label}
|
||||
desc={column.desc}
|
||||
query={search}
|
||||
items={prompts.filter((item) => item.category === column.category)}
|
||||
pulseId={pulseId}
|
||||
onCopy={copyPrompt}
|
||||
onDelete={deletePrompt}
|
||||
onDetail={setDetail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full auto-cols-[260px] grid-flow-col gap-4">
|
||||
{ASSET_COLUMNS.map((column) => (
|
||||
<AssetColumn
|
||||
key={column.kind}
|
||||
label={column.label}
|
||||
icon={column.icon}
|
||||
query={search}
|
||||
items={assets[column.kind]}
|
||||
pulseId={pulseId}
|
||||
onApply={applyAsset}
|
||||
onDelete={deleteAsset}
|
||||
onDetail={setDetail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LibraryDetailPanel item={detail} />
|
||||
</main>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="调整资源库浮窗大小"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
dragRef.current = { mode: "resize", x: event.clientX, y: event.clientY, rect }
|
||||
}}
|
||||
className="absolute bottom-1 right-1 h-5 w-5 cursor-nwse-resize rounded-sm border-b-2 border-r-2 border-[#d6b36a]/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newPromptOpen ? <LibraryNewPromptDialog currentJobId={currentJobId} onClose={() => setNewPromptOpen(false)} onSaved={(item) => { setPrompts((current) => [item, ...current]); setNewPromptOpen(false); toast.success("提示词已入库") }} /> : null}
|
||||
{uploadOpen ? <LibraryUploadDialog currentJobId={currentJobId} onClose={() => setUploadOpen(false)} onSaved={(item) => { setAssets((current) => ({ ...current, [item.kind]: [item, ...current[item.kind]] })); setUploadOpen(false); void refresh(); toast.success("素材已入库") }} /> : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryRecentStrip({ items, onPick }: { items: ResourceLibraryRecentItem[]; onPick: (item: ResourceLibraryRecentItem) => void }) {
|
||||
return (
|
||||
<div className="h-[100px] shrink-0 border-b border-white/10 bg-black/24 px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#d6b36a]">最近 24 小时</div>
|
||||
<div className="text-[10px] text-white/34">{items.length ? `${items.length} 个新增` : "暂无新增"}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{items.length ? items.map((item) => (
|
||||
<button key={`${item.type}-${item.id}`} type="button" onClick={() => onPick(item)} className="relative h-[72px] w-[80px] shrink-0 overflow-hidden rounded-md border border-white/10 bg-white/[0.035] text-left hover:border-[#d6b36a]/55">
|
||||
<span className="absolute left-1 top-1 z-10 rounded bg-black/72 px-1 text-[9px] text-white/75">{item.type === "asset" ? "素" : "词"}</span>
|
||||
<div className="flex h-full items-end p-1 text-[10px] leading-tight text-white/72">
|
||||
<span className="line-clamp-2">{item.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="flex h-[72px] items-center text-[11px] text-white/34">新建或上传后会出现在这里。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptColumn({ label, desc, items, query, pulseId, onCopy, onDelete, onDetail }: {
|
||||
label: string
|
||||
desc: string
|
||||
items: PromptLibraryItem[]
|
||||
query: string
|
||||
pulseId: string
|
||||
onCopy: (item: PromptLibraryItem, mode?: "en" | "zh" | "both") => void
|
||||
onDelete: (item: PromptLibraryItem) => void
|
||||
onDetail: (item: PromptLibraryItem) => void
|
||||
}) {
|
||||
const common = [...items].sort((a, b) => b.use_count - a.use_count).slice(0, 5).filter((item) => item.use_count > 0)
|
||||
return (
|
||||
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.028]">
|
||||
<header className="shrink-0 border-b border-white/10 p-2">
|
||||
<div className="text-[12px] font-semibold">{label}</div>
|
||||
<div className="mt-0.5 text-[10px] text-white/35">{desc}</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
|
||||
{common.length ? (
|
||||
<>
|
||||
<Divider label="常用" />
|
||||
{common.map((item) => <PromptCard key={`common-${item.id}`} item={item} dim={!matchesPrompt(item, query)} pulse={pulseId === item.id} onCopy={onCopy} onDelete={onDelete} onDetail={onDetail} />)}
|
||||
</>
|
||||
) : null}
|
||||
{groupByMonth(items).map(([month, monthItems]) => (
|
||||
<div key={month}>
|
||||
<Divider label={month} />
|
||||
<div className="space-y-2">
|
||||
{monthItems.map((item) => <PromptCard key={item.id} item={item} dim={!matchesPrompt(item, query)} pulse={pulseId === item.id} onCopy={onCopy} onDelete={onDelete} onDetail={onDetail} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptCard({ item, dim, pulse, onCopy, onDelete, onDetail }: {
|
||||
item: PromptLibraryItem
|
||||
dim: boolean
|
||||
pulse: boolean
|
||||
onCopy: (item: PromptLibraryItem, mode?: "en" | "zh" | "both") => void
|
||||
onDelete: (item: PromptLibraryItem) => void
|
||||
onDetail: (item: PromptLibraryItem) => void
|
||||
}) {
|
||||
const isNew = Date.now() / 1000 - item.created_at < 86400
|
||||
return (
|
||||
<article className={cn("group h-[132px] rounded-md border border-white/10 bg-black/34 p-2 transition", dim && "opacity-25", pulse && "ring-2 ring-[#d6b36a]/80")}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<button type="button" onClick={() => onDetail(item)} className="min-w-0 text-left">
|
||||
<div className="truncate text-[11px] font-semibold text-white/86">{item.name}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">{item.tags.slice(0, 2).map((tag) => <span key={tag} className="rounded border border-[#d6b36a]/20 bg-[#d6b36a]/8 px-1 text-[9px] text-[#f1d78e]">{tag}</span>)}{isNew ? <span className="rounded bg-[#d6b36a]/18 px-1 text-[9px] text-[#f1d78e]">✨ 新</span> : null}</div>
|
||||
</button>
|
||||
<div className="flex opacity-0 transition group-hover:opacity-100">
|
||||
<button type="button" title="编辑" onClick={() => onDetail(item)} className="h-5 w-5 text-white/42 hover:text-white"><Edit3 className="h-3.5 w-3.5" /></button>
|
||||
<button type="button" title="删除" onClick={() => onDelete(item)} className="h-5 w-5 text-rose-200/58 hover:text-rose-100"><Trash2 className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => onDetail(item)} className="mt-2 line-clamp-2 min-h-[34px] w-full text-left text-[10.5px] leading-snug text-white/48">{item.prompt_en || item.prompt_zh}</button>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] text-white/34">
|
||||
<CopyButton onCopy={(mode) => onCopy(item, mode)} />
|
||||
<span>{item.use_count} 次使用 · {formatAgo(item.created_at)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyButton({ onCopy }: { onCopy: (mode: "en" | "zh" | "both") => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<span className="relative" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
|
||||
<button type="button" onClick={() => onCopy("en")} className="inline-flex h-6 items-center gap-1 rounded bg-[#f5efe3] px-2 text-[10px] font-semibold text-black">
|
||||
<Copy className="h-3 w-3" />
|
||||
复制
|
||||
</button>
|
||||
{open ? (
|
||||
<span className="absolute bottom-7 left-0 z-20 flex rounded-md border border-white/12 bg-black/94 p-1 shadow-xl">
|
||||
{[
|
||||
["en", "英文"],
|
||||
["zh", "中文"],
|
||||
["both", "双语"],
|
||||
].map(([mode, label]) => (
|
||||
<button key={mode} type="button" onClick={() => onCopy(mode as "en" | "zh" | "both")} className="whitespace-nowrap rounded px-2 py-1 text-[10px] text-white/68 hover:bg-white/10 hover:text-white">{label}</button>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AssetColumn({ label, icon, items, query, pulseId, onApply, onDelete, onDetail }: {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
items: AssetLibraryItem[]
|
||||
query: string
|
||||
pulseId: string
|
||||
onApply: (item: AssetLibraryItem) => void
|
||||
onDelete: (item: AssetLibraryItem) => void
|
||||
onDetail: (item: AssetLibraryItem) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.028]">
|
||||
<header className="flex shrink-0 items-center gap-1.5 border-b border-white/10 p-2 text-[12px] font-semibold">{icon}{label}</header>
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-2">
|
||||
{groupByMonth(items).map(([month, monthItems]) => (
|
||||
<div key={month}>
|
||||
<Divider label={month} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{monthItems.map((item) => <AssetCard key={item.id} item={item} dim={!matchesAsset(item, query)} pulse={pulseId === item.id} onApply={onApply} onDelete={onDelete} onDetail={onDetail} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AssetCard({ item, dim, pulse, onApply, onDelete, onDetail }: {
|
||||
item: AssetLibraryItem
|
||||
dim: boolean
|
||||
pulse: boolean
|
||||
onApply: (item: AssetLibraryItem) => void
|
||||
onDelete: (item: AssetLibraryItem) => void
|
||||
onDetail: (item: AssetLibraryItem) => void
|
||||
}) {
|
||||
const horizontal = item.kind === "videos"
|
||||
return (
|
||||
<div className={cn("group rounded-md border border-white/10 bg-black/34 p-1.5 transition", dim && "opacity-25", pulse && "ring-2 ring-[#d6b36a]/80", horizontal && "col-span-2")}>
|
||||
<MediaAssetTile
|
||||
kind={item.kind === "videos" ? "video" : "image"}
|
||||
src={item.kind === "videos" ? apiAssetUrl(item.video_url) : assetThumb(item)}
|
||||
poster={item.kind === "videos" ? assetThumb(item) : undefined}
|
||||
label={item.name}
|
||||
meta={assetMeta(item)}
|
||||
className={horizontal ? "aspect-video w-full bg-white" : "aspect-[120/156] w-full bg-white"}
|
||||
objectFit="cover"
|
||||
onClick={() => onDetail(item)}
|
||||
actions={[
|
||||
{ key: "copy", label: "复制 ID", icon: <Copy className="h-3 w-3" />, onClick: () => { void navigator.clipboard.writeText(item.id); toast.success("素材 ID 已复制") } },
|
||||
{ key: "apply", label: "应用到当前 job", icon: <Download className="h-3 w-3" />, onClick: () => void onApply(item), tone: "cyan" },
|
||||
{ key: "edit", label: "编辑", icon: <Edit3 className="h-3 w-3" />, onClick: () => onDetail(item) },
|
||||
]}
|
||||
onDelete={() => void onDelete(item)}
|
||||
/>
|
||||
<div className="mt-1 min-w-0">
|
||||
<div className="truncate text-[10.5px] font-semibold text-white/78">{item.name}</div>
|
||||
<div className="truncate text-[9.5px] text-white/34">{assetMeta(item)} · {item.use_count} 次</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider({ label }: { label: string }) {
|
||||
return <div className="my-2 flex items-center gap-2 text-[9px] uppercase tracking-[0.16em] text-white/28"><span className="h-px flex-1 bg-white/10" />{label}<span className="h-px flex-1 bg-white/10" /></div>
|
||||
}
|
||||
|
||||
function LibraryDetailPanel({ item }: { item: PromptLibraryItem | AssetLibraryItem | null }) {
|
||||
if (!item) {
|
||||
return <aside className="border-l border-white/10 p-3 text-[11px] text-white/36">选择一个节点查看详情。</aside>
|
||||
}
|
||||
const isPrompt = "prompt_en" in item
|
||||
return (
|
||||
<aside className="min-h-0 overflow-y-auto border-l border-white/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold">
|
||||
{isPrompt ? <FileText className="h-4 w-4 text-[#d6b36a]" /> : <Database className="h-4 w-4 text-[#d6b36a]" />}
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="space-y-2 text-[11px] leading-relaxed text-white/58">
|
||||
<p>ID:<span className="font-mono text-white/42">{item.id}</span></p>
|
||||
<p>使用:{item.use_count} 次</p>
|
||||
<p>创建:{formatAgo(item.created_at)}</p>
|
||||
<p>标签:{item.tags?.join(" / ") || "-"}</p>
|
||||
{isPrompt ? (
|
||||
<>
|
||||
<div><div className="mb-1 text-white/34">英文</div><pre className="whitespace-pre-wrap rounded border border-white/10 bg-black/36 p-2 text-[10.5px]">{(item as PromptLibraryItem).prompt_en}</pre></div>
|
||||
<div><div className="mb-1 text-white/34">中文</div><pre className="whitespace-pre-wrap rounded border border-white/10 bg-black/36 p-2 text-[10.5px]">{(item as PromptLibraryItem).prompt_zh || "-"}</pre></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>备注:{(item as AssetLibraryItem).note || "-"}</p>
|
||||
<p>来源 job:{(item as AssetLibraryItem).source_job_id || "-"}</p>
|
||||
<p>brief:{(item as AssetLibraryItem).prompt_brief || "-"}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryNewPromptDialog({ currentJobId, onClose, onSaved }: { currentJobId?: string; onClose: () => void; onSaved: (item: PromptLibraryItem) => void }) {
|
||||
const [category, setCategory] = useState<PromptLibraryCategory>("scene_desc")
|
||||
const [name, setName] = useState("")
|
||||
const [tags, setTags] = useState("")
|
||||
const [promptEn, setPromptEn] = useState("")
|
||||
const [promptZh, setPromptZh] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const item = await createPromptLibraryItem({
|
||||
category,
|
||||
name,
|
||||
tags: tags.split(/[,,\s]+/).map((tag) => tag.trim()).filter(Boolean),
|
||||
prompt_en: promptEn,
|
||||
prompt_zh: promptZh,
|
||||
source_job_id: currentJobId || "",
|
||||
})
|
||||
onSaved(item)
|
||||
} catch (error) {
|
||||
toast.error("提示词入库失败:" + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return <DialogFrame title="新建提示词" onClose={onClose}>
|
||||
<div className="grid gap-2">
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value as PromptLibraryCategory)} className="h-9 rounded border border-white/10 bg-black px-2 text-[12px]">
|
||||
{PROMPT_COLUMNS.map((column) => <option key={column.category} value={column.category}>{column.label}</option>)}
|
||||
</select>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="标题" className="h-9 rounded border border-white/10 bg-black px-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
|
||||
<input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="标签,用空格或逗号分隔" className="h-9 rounded border border-white/10 bg-black px-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
|
||||
<textarea value={promptEn} onChange={(event) => setPromptEn(event.target.value)} placeholder="英文内容,实际发给模型" className="min-h-[112px] resize-y rounded border border-white/10 bg-black px-2 py-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
|
||||
<textarea value={promptZh} onChange={(event) => setPromptZh(event.target.value)} placeholder="中文翻译,给团队看" className="min-h-[72px] resize-y rounded border border-white/10 bg-black px-2 py-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="skg-secondary-action h-9 px-3 text-[12px]">取消</button>
|
||||
<button type="button" onClick={() => void save()} disabled={busy || !name.trim() || (!promptEn.trim() && !promptZh.trim())} className="skg-primary-action h-9 px-3 text-[12px] disabled:opacity-40">{busy ? "保存中" : "保存"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
}
|
||||
|
||||
function LibraryUploadDialog({ currentJobId, onClose, onSaved }: { currentJobId?: string; onClose: () => void; onSaved: (item: AssetLibraryItem) => void }) {
|
||||
const [kind, setKind] = useState<AssetLibraryKind>("subjects")
|
||||
const [name, setName] = useState("")
|
||||
const [note, setNote] = useState("")
|
||||
const [tags, setTags] = useState("")
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const item = await createAssetLibraryItem(kind, {
|
||||
name,
|
||||
note,
|
||||
tags: tags.split(/[,,\s]+/).map((tag) => tag.trim()).filter(Boolean),
|
||||
source_job_id: currentJobId || "",
|
||||
}, files)
|
||||
onSaved(item)
|
||||
} catch (error) {
|
||||
toast.error("素材入库失败:" + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return <DialogFrame title="上传素材" onClose={onClose}>
|
||||
<div className="grid gap-2">
|
||||
<select value={kind} onChange={(event) => setKind(event.target.value as AssetLibraryKind)} className="h-9 rounded border border-white/10 bg-black px-2 text-[12px]">
|
||||
{ASSET_COLUMNS.map((column) => <option key={column.kind} value={column.kind}>{column.label}</option>)}
|
||||
</select>
|
||||
<input type="file" multiple onChange={(event) => setFiles(Array.from(event.currentTarget.files ?? []))} className="rounded border border-dashed border-white/12 bg-black/35 p-3 text-[12px] text-white/56 file:mr-3 file:rounded file:border-0 file:bg-[#f5efe3] file:px-2 file:py-1 file:text-black" />
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="名称" className="h-9 rounded border border-white/10 bg-black px-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
|
||||
<input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="标签,用空格或逗号分隔" className="h-9 rounded border border-white/10 bg-black px-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
|
||||
<textarea value={note} onChange={(event) => setNote(event.target.value)} placeholder="备注 / 视角 / brief" className="min-h-[80px] resize-y rounded border border-white/10 bg-black px-2 py-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="skg-secondary-action h-9 px-3 text-[12px]">取消</button>
|
||||
<button type="button" onClick={() => void save()} disabled={busy || !name.trim() || !files.length} className="skg-primary-action h-9 px-3 text-[12px] disabled:opacity-40">{busy ? "保存中" : "保存"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
}
|
||||
|
||||
function DialogFrame({ title, children, onClose }: { title: string; children: ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-[9100] flex items-center justify-center bg-black/45">
|
||||
<div className="w-[520px] rounded-xl border border-white/12 bg-[#080808] p-4 text-white shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-[14px] font-semibold">{title}</div>
|
||||
<button type="button" onClick={onClose} className="h-7 w-7 rounded border border-white/10 text-white/55 hover:text-white"><X className="mx-auto h-4 w-4" /></button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
551
web/lib/api.ts
551
web/lib/api.ts
@@ -58,11 +58,13 @@ export interface KeyElement {
|
||||
cutout_background?: "white" | "black"
|
||||
subject_kind?: SubjectKind
|
||||
subject_assets?: SubjectAsset[]
|
||||
subject_consensus_brief?: string
|
||||
subject_consensus_brief_zh?: string
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface ImageRef {
|
||||
kind: "keyframe" | "cutout" | "asset"
|
||||
kind: "keyframe" | "cutout" | "asset" | "library_subject" | "library_product" | "library_scene"
|
||||
frame_idx: number
|
||||
element_id?: string | null
|
||||
cutout_id?: string | null
|
||||
@@ -85,6 +87,69 @@ export interface ImageRef {
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptLibraryCategory = "scene_desc" | "video_desc" | "subject_desc" | "skg_script" | "product_angle"
|
||||
export type AssetLibraryKind = "subjects" | "products" | "scenes" | "videos"
|
||||
|
||||
export interface PromptLibraryItem {
|
||||
id: string
|
||||
category: PromptLibraryCategory
|
||||
name: string
|
||||
tags: string[]
|
||||
prompt_en: string
|
||||
prompt_zh: string
|
||||
use_count: number
|
||||
source_job_id: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface AssetLibraryImage {
|
||||
id: string
|
||||
view: string
|
||||
label: string
|
||||
filename: string
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface AssetLibraryItem {
|
||||
id: string
|
||||
kind: AssetLibraryKind
|
||||
name: string
|
||||
name_zh?: string
|
||||
note?: string
|
||||
tags: string[]
|
||||
source_job_id?: string
|
||||
use_count: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
is_official?: boolean
|
||||
prompt_brief?: string
|
||||
prompt_brief_zh?: string
|
||||
subject_style?: "transparent_human" | "source_actor" | "cartoon_subject"
|
||||
product_type?: string
|
||||
views?: AssetLibraryImage[]
|
||||
images?: AssetLibraryImage[]
|
||||
asset_role?: string
|
||||
aspect_ratio?: string
|
||||
image?: AssetLibraryImage | null
|
||||
duration?: number
|
||||
poster?: AssetLibraryImage | null
|
||||
video_url?: string
|
||||
}
|
||||
|
||||
export interface ResourceLibraryRecentItem {
|
||||
type: "prompt" | "asset"
|
||||
id: string
|
||||
name: string
|
||||
category?: PromptLibraryCategory
|
||||
kind?: AssetLibraryKind
|
||||
created_at: number
|
||||
item: PromptLibraryItem | AssetLibraryItem
|
||||
}
|
||||
|
||||
export interface ProductFusionRegion {
|
||||
x: number
|
||||
y: number
|
||||
@@ -122,6 +187,15 @@ export interface StoryboardScene {
|
||||
visual_mode?: "person_only" | "person_product" | "product_only" | "environment"
|
||||
needs_product?: boolean
|
||||
needs_subject?: boolean
|
||||
storyboard_row_idx?: number | null
|
||||
subject_brief?: string
|
||||
skg_copy_en?: string
|
||||
skg_copy_zh?: string
|
||||
scene_one_line_en?: string
|
||||
scene_one_line_zh?: string
|
||||
action_one_line_en?: string
|
||||
action_one_line_zh?: string
|
||||
selected_video_id?: string
|
||||
first_frame_plan?: string
|
||||
last_frame_plan?: string
|
||||
product_placement?: string
|
||||
@@ -137,10 +211,38 @@ export interface StoryboardScene {
|
||||
reference_ids?: string[]
|
||||
}
|
||||
|
||||
export interface QuickStoryboardPlanInput {
|
||||
skg_copy_en?: string
|
||||
skg_copy_zh?: string
|
||||
scene_one_line_en?: string
|
||||
scene_one_line_zh?: string
|
||||
action_one_line_en?: string
|
||||
action_one_line_zh?: string
|
||||
subject_brief?: string
|
||||
duration?: number
|
||||
visual_mode?: StoryboardScene["visual_mode"]
|
||||
needs_product?: boolean
|
||||
needs_subject?: boolean
|
||||
}
|
||||
|
||||
export interface RefineStoryboardResult {
|
||||
items: {
|
||||
skg_copy_en: string
|
||||
skg_copy_zh: string
|
||||
scene_one_line_en: string
|
||||
scene_one_line_zh: string
|
||||
action_one_line_en: string
|
||||
action_one_line_zh: string
|
||||
}
|
||||
model: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface GeneratedVideo {
|
||||
id: string
|
||||
provider_id?: string
|
||||
frame_idx: number
|
||||
storyboard_row_idx?: number | null
|
||||
prompt: string
|
||||
model: string
|
||||
status: "queued" | "in_progress" | "completed" | "failed"
|
||||
@@ -154,6 +256,12 @@ export interface GeneratedVideo {
|
||||
|
||||
export interface RuntimeModels {
|
||||
asr?: string
|
||||
asr_language?: string
|
||||
asr_base_url?: string
|
||||
asr_remote_enabled?: boolean
|
||||
asr_local_fallback_enabled?: boolean
|
||||
asr_audio_fallback_enabled?: boolean
|
||||
faster_whisper?: string
|
||||
local_asr?: string
|
||||
asr_fallback?: string
|
||||
translate?: string
|
||||
@@ -164,6 +272,16 @@ export interface RuntimeModels {
|
||||
image?: string
|
||||
image_base_url?: string
|
||||
image_fallbacks?: string[]
|
||||
image_circuit?: {
|
||||
primary?: string
|
||||
fallbacks?: string[]
|
||||
failure_threshold?: number
|
||||
cooldown_seconds?: number
|
||||
primary_failures?: number
|
||||
primary_open?: boolean
|
||||
primary_open_until?: number
|
||||
primary_open_remaining_seconds?: number
|
||||
}
|
||||
subject_image?: string
|
||||
subject_image_fallbacks?: string[]
|
||||
voice_provider?: string
|
||||
@@ -172,10 +290,7 @@ export interface RuntimeModels {
|
||||
voice_id?: string
|
||||
voice_pool?: string[]
|
||||
voice_configured?: boolean
|
||||
minimax_tts?: string
|
||||
minimax_voice?: string
|
||||
minimax_voice_pool?: string[]
|
||||
minimax_configured?: boolean
|
||||
voice_tts_paths?: string[]
|
||||
video?: string
|
||||
video_aliases?: Record<string, string>
|
||||
video_provider?: string
|
||||
@@ -209,6 +324,9 @@ export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
|
||||
if (ref.kind === "asset" && ref.element_id) {
|
||||
return `${API_BASE}/jobs/${jobId}/assets/${ref.element_id}.jpg`
|
||||
}
|
||||
if (ref.kind === "library_subject" || ref.kind === "library_product" || ref.kind === "library_scene") {
|
||||
return ""
|
||||
}
|
||||
if (ref.element_id && ref.cutout_id) {
|
||||
if (ref.cutout_id === ref.element_id) {
|
||||
// legacy v1
|
||||
@@ -262,7 +380,7 @@ export async function rewriteStoryboardScript(
|
||||
author_intent?: string
|
||||
segments: StoryboardScriptRewriteSegment[]
|
||||
},
|
||||
): Promise<{ items: Array<{ index: number; text: string }> }> {
|
||||
): Promise<{ items: Array<{ index: number; text: string; text_zh?: string }> }> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/script/rewrite`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -359,6 +477,173 @@ export function characterLibraryImageUrl(filename: string): string {
|
||||
return `${API_BASE}/character-library/skg/images/${filename}`
|
||||
}
|
||||
|
||||
export async function listSubjectTemplates(): Promise<SubjectTemplateItem[]> {
|
||||
const res = await fetch(`${API_BASE}/subject-templates`)
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`listSubjectTemplates ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function subjectTemplateImageUrl(filename: string): string {
|
||||
return `${API_BASE}/subject-templates/images/${filename}`
|
||||
}
|
||||
|
||||
export async function saveSubjectTemplate(
|
||||
jobId: string,
|
||||
body: {
|
||||
name: string
|
||||
note?: string
|
||||
frame_idx: number
|
||||
element_id: string
|
||||
asset_ids: string[]
|
||||
subject_style?: "transparent_human" | "source_actor" | "cartoon_subject"
|
||||
},
|
||||
): Promise<SubjectTemplateItem> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-templates`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: body.name,
|
||||
note: body.note ?? "",
|
||||
frame_idx: body.frame_idx,
|
||||
element_id: body.element_id,
|
||||
asset_ids: body.asset_ids,
|
||||
subject_style: body.subject_style ?? "transparent_human",
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`saveSubjectTemplate ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function assetLibraryFileUrl(kind: AssetLibraryKind, itemId: string, filename: string): string {
|
||||
return `${API_BASE}/asset-library/${kind}/${itemId}/file/${filename}`
|
||||
}
|
||||
|
||||
export async function listPromptLibrary(category?: PromptLibraryCategory, q = ""): Promise<PromptLibraryItem[]> {
|
||||
const qs = new URLSearchParams()
|
||||
if (category) qs.set("category", category)
|
||||
if (q.trim()) qs.set("q", q.trim())
|
||||
const res = await fetch(`${API_BASE}/prompt-library${qs.toString() ? `?${qs}` : ""}`, { cache: "no-store" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`listPromptLibrary ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createPromptLibraryItem(body: {
|
||||
category: PromptLibraryCategory
|
||||
name: string
|
||||
tags?: string[]
|
||||
prompt_en: string
|
||||
prompt_zh?: string
|
||||
source_job_id?: string
|
||||
}): Promise<PromptLibraryItem> {
|
||||
const res = await fetch(`${API_BASE}/prompt-library`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category: body.category,
|
||||
name: body.name,
|
||||
tags: body.tags ?? [],
|
||||
prompt_en: body.prompt_en,
|
||||
prompt_zh: body.prompt_zh ?? "",
|
||||
source_job_id: body.source_job_id ?? "",
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`createPromptLibraryItem ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function usePromptLibraryItem(id: string): Promise<PromptLibraryItem> {
|
||||
const res = await fetch(`${API_BASE}/prompt-library/${id}/use`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`usePromptLibraryItem ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deletePromptLibraryItem(id: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`${API_BASE}/prompt-library/${id}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`deletePromptLibraryItem ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function listAssetLibrary(kind: AssetLibraryKind, q = ""): Promise<AssetLibraryItem[]> {
|
||||
const qs = new URLSearchParams()
|
||||
if (q.trim()) qs.set("q", q.trim())
|
||||
const res = await fetch(`${API_BASE}/asset-library/${kind}${qs.toString() ? `?${qs}` : ""}`, { cache: "no-store" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`listAssetLibrary ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createAssetLibraryItem(
|
||||
kind: AssetLibraryKind,
|
||||
metadata: Record<string, unknown>,
|
||||
files: File[],
|
||||
): Promise<AssetLibraryItem> {
|
||||
const fd = new FormData()
|
||||
fd.append("metadata", JSON.stringify(metadata))
|
||||
for (const file of files) fd.append("files", file)
|
||||
const res = await fetch(`${API_BASE}/asset-library/${kind}`, { method: "POST", body: fd })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`createAssetLibraryItem ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getAssetLibraryRefs(kind: AssetLibraryKind, id: string): Promise<{ count: number; jobs: string[] }> {
|
||||
const res = await fetch(`${API_BASE}/asset-library/${kind}/${id}/refs`, { cache: "no-store" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`getAssetLibraryRefs ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteAssetLibraryItem(kind: AssetLibraryKind, id: string, force = false): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`${API_BASE}/asset-library/${kind}/${id}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`deleteAssetLibraryItem ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function copyAssetLibraryToJob(kind: AssetLibraryKind, id: string, jobId: string): Promise<ImageRef | { kind: "video"; video_id: string; url: string; label?: string }> {
|
||||
const res = await fetch(`${API_BASE}/asset-library/${kind}/${id}/copy-to-job/${jobId}`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`copyAssetLibraryToJob ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getResourceLibraryRecent(hours = 24): Promise<{ items: ResourceLibraryRecentItem[] }> {
|
||||
const res = await fetch(`${API_BASE}/resource-library/recent?hours=${encodeURIComponent(String(hours))}`, { cache: "no-store" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`getResourceLibraryRecent ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function copyCharacterLibraryAssets(jobId: string, characterId: string): Promise<{ character_id: string; character_name: string; images: ImageRef[] }> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/character-library`, {
|
||||
method: "POST",
|
||||
@@ -426,6 +711,9 @@ export type AssetBackground = "white" | "black"
|
||||
export type AssetSize = "source" | "1024" | "1536" | "2048"
|
||||
export type SubjectKind = "object" | "living"
|
||||
export type SubjectView = string
|
||||
export type SubjectAssetStatus = "queued" | "in_progress" | "completed" | "failed"
|
||||
export type SubjectImageModelPreference = "auto" | "gpt-image-2" | "gemini-3-pro-image-preview"
|
||||
export type SubjectModelBundle = "gpt" | "gemini"
|
||||
export type SceneMode = "remove_subject" | "similar" | "style"
|
||||
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"
|
||||
export type SceneAssetRole = "scene" | "first_frame" | "last_frame"
|
||||
@@ -479,9 +767,63 @@ export interface SubjectAsset {
|
||||
size: AssetSize
|
||||
source_frame_indices?: number[]
|
||||
ai_completed?: boolean
|
||||
status?: SubjectAssetStatus
|
||||
progress?: number
|
||||
error?: string
|
||||
pack_id?: string
|
||||
pack_label?: string
|
||||
pack_mode?: string
|
||||
pack_created_at?: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface SubjectProfilePreference {
|
||||
mode?: "random" | "manual"
|
||||
gender?: string
|
||||
age?: string
|
||||
wardrobe?: string
|
||||
region_ethnicity?: string
|
||||
skin_tone?: string
|
||||
body?: string
|
||||
hair?: string
|
||||
mood?: string
|
||||
resolved_summary?: string
|
||||
prompt_summary?: string
|
||||
}
|
||||
|
||||
export interface SubjectAgentAnalysis {
|
||||
model_bundle: SubjectModelBundle
|
||||
model: string
|
||||
source_frame_indices: number[]
|
||||
summary_zh: string
|
||||
summary_en: string
|
||||
generation_brief_en: string
|
||||
trait_chips: string[]
|
||||
mode_options: string[]
|
||||
questions: string[]
|
||||
warnings: string[]
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface SubjectAgentMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface SubjectAgentState {
|
||||
model_bundle: SubjectModelBundle
|
||||
source_frame_indices: number[]
|
||||
analysis?: SubjectAgentAnalysis | null
|
||||
messages: SubjectAgentMessage[]
|
||||
selected_mode: "realistic" | "cartoon" | "elements" | "custom"
|
||||
selected_traits: string[]
|
||||
requirements_zh: string
|
||||
generation_prompt_en: string
|
||||
quantity: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface ProductLibraryItem {
|
||||
id: string
|
||||
handle: string
|
||||
@@ -516,10 +858,46 @@ export interface CharacterLibraryItem {
|
||||
name: string
|
||||
folder: string
|
||||
description: string
|
||||
prompt_brief?: string
|
||||
prompt_brief_zh?: string
|
||||
primary_image: string
|
||||
images: CharacterLibraryImage[]
|
||||
}
|
||||
|
||||
export interface SubjectTemplateImage {
|
||||
id: string
|
||||
view: string
|
||||
label: string
|
||||
filename: string
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
background: "white" | "black"
|
||||
quality: "hd"
|
||||
size: "source" | "1024" | "1536" | "2048"
|
||||
source_asset_id: string
|
||||
source_frame_indices: number[]
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface SubjectTemplateItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
note: string
|
||||
prompt_brief?: string
|
||||
prompt_brief_zh?: string
|
||||
source: "database"
|
||||
source_job_id: string
|
||||
source_frame_idx: number
|
||||
source_element_id: string
|
||||
subject_style: "transparent_human" | "source_actor" | "cartoon_subject"
|
||||
primary_image: string
|
||||
images: SubjectTemplateImage[]
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface TranscriptSegment {
|
||||
index: number
|
||||
start: number
|
||||
@@ -533,6 +911,7 @@ export interface AudioScript {
|
||||
source_text: string
|
||||
source_zh: string
|
||||
rewritten_text: string
|
||||
rewritten_text_zh?: string
|
||||
speaker_profile: string
|
||||
rhythm_profile: string
|
||||
background_audio_profile: string
|
||||
@@ -588,6 +967,7 @@ export interface Job {
|
||||
storyboard_images?: StoryboardImage[]
|
||||
generated_videos?: GeneratedVideo[]
|
||||
product_refs?: ProductRefStateItem[]
|
||||
subject_agent?: SubjectAgentState
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -601,9 +981,7 @@ export interface BackendHealth {
|
||||
translate?: string
|
||||
rewrite?: string
|
||||
audio_rewrite?: string
|
||||
minimax_tts?: string
|
||||
minimax_voice?: string
|
||||
minimax_configured?: boolean
|
||||
voice_tts_paths?: string[]
|
||||
video?: string
|
||||
video_aliases?: Record<string, string>
|
||||
video_base_url?: string
|
||||
@@ -617,6 +995,25 @@ export function apiAssetUrl(path?: string | null): string {
|
||||
return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`
|
||||
}
|
||||
|
||||
export function isRestrictedDownloadError(error?: string | null): boolean {
|
||||
const text = (error ?? "").toLowerCase()
|
||||
return (
|
||||
text.includes("tiktok 下载需要登录态") ||
|
||||
text.includes("log in for access") ||
|
||||
text.includes("cookies-from-browser") ||
|
||||
text.includes("ytdlp_cookies_file") ||
|
||||
(text.includes("tiktok") && text.includes("cookies"))
|
||||
)
|
||||
}
|
||||
|
||||
export function formatJobError(error?: string | null): string {
|
||||
if (!error) return ""
|
||||
if (isRestrictedDownloadError(error)) {
|
||||
return "这个 TikTok 视频需要登录态。请上传 MP4,或让后端配置 YTDLP_COOKIES_FROM_BROWSER / YTDLP_COOKIES_FILE 后重试。"
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
export async function getHealth(): Promise<BackendHealth> {
|
||||
const res = await fetch(`${API_BASE}/health`)
|
||||
if (!res.ok) throw new Error(`health ${res.status}`)
|
||||
@@ -633,6 +1030,15 @@ export async function createJob(tkUrl: string): Promise<Job> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function retryJobDownload(id: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${id}/download/retry`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "")
|
||||
throw apiError("retryJobDownload", res.status, text)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function uploadJob(file: File): Promise<Job> {
|
||||
const fd = new FormData()
|
||||
fd.append("file", file)
|
||||
@@ -717,6 +1123,17 @@ export async function addManualFrame(id: string, t: number): Promise<Job> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function uploadReferenceFrame(jobId: string, file: File): Promise<Job> {
|
||||
const fd = new FormData()
|
||||
fd.append("file", file)
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/upload`, { method: "POST", body: fd })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`uploadReferenceFrame ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function describeFrame(jobId: string, frameIdx: number): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/describe`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
@@ -870,12 +1287,65 @@ export async function updateStoryboard(
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function quickPlanStoryboard(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
body: QuickStoryboardPlanInput,
|
||||
): Promise<StoryboardScene> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/quick-plan`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`quickPlanStoryboard ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function refineStoryboard(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
body: { current_plan: QuickStoryboardPlanInput; user_feedback: string },
|
||||
): Promise<RefineStoryboardResult> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/refine`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`refineStoryboard ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function batchGenerateAll(
|
||||
jobId: string,
|
||||
body: { count_per_row?: number; concurrency?: number; model?: string; size?: string },
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard/batch-generate-all`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`batchGenerateAll ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function generateStoryboardVideo(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
body: {
|
||||
prompt: string
|
||||
duration?: number
|
||||
count?: number
|
||||
seed?: number | null
|
||||
storyboard_row_idx?: number | null
|
||||
first_image?: ImageRef | null
|
||||
last_image?: ImageRef | null
|
||||
product_images?: ImageRef[]
|
||||
@@ -997,7 +1467,7 @@ export async function updateElement(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
elementId: string,
|
||||
body: { name_zh?: string; name_en?: string; position?: string },
|
||||
body: { name_zh?: string; name_en?: string; position?: string; subject_consensus_brief?: string },
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, {
|
||||
method: "PATCH",
|
||||
@@ -1049,6 +1519,7 @@ export async function generateSceneAsset(
|
||||
scene_style?: SceneStyle
|
||||
asset_role?: SceneAssetRole
|
||||
prompt?: string
|
||||
subject_brief?: string
|
||||
source_frame_indices?: number[]
|
||||
subject_images?: ImageRef[]
|
||||
product_images?: ImageRef[]
|
||||
@@ -1064,6 +1535,7 @@ export async function generateSceneAsset(
|
||||
scene_style: body.scene_style ?? "source",
|
||||
asset_role: body.asset_role ?? "scene",
|
||||
prompt: body.prompt ?? "",
|
||||
subject_brief: body.subject_brief ?? "",
|
||||
source_frame_indices: body.source_frame_indices ?? null,
|
||||
subject_images: body.subject_images ?? [],
|
||||
product_images: body.product_images ?? [],
|
||||
@@ -1087,10 +1559,17 @@ export async function generateSubjectAssets(
|
||||
source_frame_indices?: number[]
|
||||
views?: string[]
|
||||
character_id?: string
|
||||
subject_style?: "transparent_human" | "source_actor"
|
||||
subject_template_id?: string
|
||||
subject_style?: "transparent_human" | "source_actor" | "cartoon_subject"
|
||||
reconstruction_mode?: "same" | "similar"
|
||||
subject_profile?: SubjectProfilePreference | null
|
||||
prompt?: string
|
||||
image_model_preference?: SubjectImageModelPreference
|
||||
replace_views?: boolean
|
||||
pack_id?: string
|
||||
pack_label?: string
|
||||
pack_mode?: string
|
||||
pack_created_at?: number
|
||||
} = {},
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets`, {
|
||||
@@ -1104,10 +1583,17 @@ export async function generateSubjectAssets(
|
||||
source_frame_indices: body.source_frame_indices ?? null,
|
||||
views: body.views ?? null,
|
||||
character_id: body.character_id ?? "",
|
||||
subject_template_id: body.subject_template_id ?? "",
|
||||
subject_style: body.subject_style ?? "transparent_human",
|
||||
reconstruction_mode: body.reconstruction_mode ?? "same",
|
||||
subject_profile: body.subject_profile ?? null,
|
||||
prompt: body.prompt ?? "",
|
||||
image_model_preference: body.image_model_preference ?? "auto",
|
||||
replace_views: body.replace_views ?? false,
|
||||
pack_id: body.pack_id ?? "",
|
||||
pack_label: body.pack_label ?? "",
|
||||
pack_mode: body.pack_mode ?? "",
|
||||
pack_created_at: body.pack_created_at ?? 0,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -1117,6 +1603,49 @@ export async function generateSubjectAssets(
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function analyzeSubjectAgent(
|
||||
jobId: string,
|
||||
body: {
|
||||
model_bundle: SubjectModelBundle
|
||||
source_frame_indices: number[]
|
||||
},
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-agent/analyze`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw apiError("subjectAgentAnalyze", res.status, txt)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function sendSubjectAgentMessage(
|
||||
jobId: string,
|
||||
body: {
|
||||
model_bundle: SubjectModelBundle
|
||||
source_frame_indices: number[]
|
||||
selected_mode: SubjectAgentState["selected_mode"]
|
||||
selected_traits: string[]
|
||||
requirements_zh: string
|
||||
message: string
|
||||
quantity: number
|
||||
},
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-agent/message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw apiError("subjectAgentMessage", res.status, txt)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteSubjectAsset(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
|
||||
128
内部分享-口播脚本.md
Normal file
128
内部分享-口播脚本.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# SKG 营销内容工作台 · 内部分享口播脚本
|
||||
|
||||
> 听众:业务 / 市场同事 · 时长:约 30 分钟 · 风格:口语、实在、不吹
|
||||
> 用法:直接照着念就行。括号里是给你自己的提醒,不用念出来。
|
||||
|
||||
---
|
||||
|
||||
## 开场(约 1 分钟)
|
||||
|
||||
大家好。今天用半小时,讲一个我们最近在做的工具——帮咱们**快速复刻爆款视频**的工作台。
|
||||
|
||||
先把丑话说前面:它**不是要替代人**,也不保证一键出爆款。它的定位很实在——**站在已经被市场验证过的爆款上,快速帮你做出一个 60 分的初稿,剩下的交给人审核和打磨。** 我今天就讲清楚:为什么这么做、怎么用、帮大家省了什么、以后往哪走。
|
||||
|
||||
---
|
||||
|
||||
## ① 背景:为什么做这个东西(约 6 分钟)
|
||||
|
||||
先想一个问题:**一条爆款视频,凭什么火?**
|
||||
|
||||
它火,说明三样东西配合得好——**声音(包含节奏)、文案(精准、不生硬)、画面(和谐、不排斥)**。这三样凑齐了,它才能跑出来。而且关键是:**它已经被市场验证过了**。它能火不是猜的,是真有人看、真有转化。
|
||||
|
||||
那咱们做内容的同事天天在干啥?刷到一条爆款,心里想"这个能跑,咱们也照着做一条,换上 SKG 自己的产品"。这个思路本身没问题——**与其从零拍脑袋想创意、赌它能不能火,不如站在一个已经验证过的爆款上,把它的声音、文案、画面复刻成相似的版本,再把我们的产品穿插进去。** 成功的底子是现成的。
|
||||
|
||||
但问题在于——**人工复刻这件事,又慢又碎又耗人**:
|
||||
|
||||
- 视频得扒下来,文案一句句听、一句句翻;
|
||||
- 文案要重写,还得把产品自然地塞进去,塞硬了观众一眼就出戏;
|
||||
- 画面要重新做,找模特拍、找设计出图,排期好几天;
|
||||
- 最后还要剪、要配。
|
||||
|
||||
每一步都不难,但都得人盯着,特别耗时间。等你吭哧吭哧做完,**爆款的热度可能已经过去了**。
|
||||
|
||||
所以我们的想法很简单:**这条复刻的流水线,让 AI 先替我们快速跑一遍,把初稿做到 60 分。** 人就不用从零开始,直接在 60 分的基础上审核、改、提到能用的水平。
|
||||
|
||||
---
|
||||
|
||||
## ② 应用场景:它到底怎么用(约 8 分钟,建议配演示)
|
||||
|
||||
链路其实就**三步**,特别清楚。(能现场演示就边点边讲)
|
||||
|
||||
**第一步:丢一个爆款链接进去。**
|
||||
看到一条想复刻的视频,把链接贴进来,点开始。剩下的它自己跑。
|
||||
|
||||
**第二步:同时做两件事,把爆款拆成"文案"和"画面"。**
|
||||
|
||||
- 一条线**听声音、出文案**:把原视频的口播、字幕扒出来,外文的自动翻成中文,告诉你它讲了什么、节奏怎么走的。然后在这个基础上重写出**相似但更顺、并且把咱们 SKG 产品穿插进去**的新文案——目标是精准、不生硬。
|
||||
- 另一条线**看视频、复刻画面**:自动从视频里挑出关键画面(就是那些撑起节奏的代表帧),然后照着这些画面**二次创作**,换成我们自己的人物和产品,做出**相似、但和谐不排斥**的新画面。
|
||||
|
||||
**第三步:合起来出片。**
|
||||
把出好的文案,配上复刻好的画面,**生成一段段视频片段,最后剪辑拼成一条完整的视频**。
|
||||
|
||||
你看,本质就是把爆款的三要素——**声音/节奏、文案、画面**——一个个换成"我们的相似版",再装回去。因为底子是验证过的,所以这条相似版大概率也不会差。
|
||||
|
||||
**但这里我必须说实话:** AI 出来的东西,**还得人来审。** 为啥?因为 AI 觉得"有趣""真实"的,跟咱们人觉得有趣、真实的,还是有差别的。机器能快速给你一个 60 分的初稿,但那最后能不能打动人、对不对味,还得靠咱们同事的眼睛把关。**这一步我们不省,也省不了。**
|
||||
|
||||
(演示收尾)所以大家看,它干的是最累的那段——下载、翻译、出文案、复刻画面、拼片;人干的是最值钱的那段——判断和打磨。
|
||||
|
||||
---
|
||||
|
||||
## ③ 价值:帮业务省了哪些事(约 7 分钟)
|
||||
|
||||
先把话说清楚:**它是个辅助工具,不是说有了它,完全不懂的人就能做出片子。**
|
||||
|
||||
为什么这么说?因为出文案、复刻画面、剪视频——每一项其实都是**专业活儿**。文案要写得勾人、产品塞进去不生硬,画面要复刻得像、又和谐,剪辑要卡得上节奏——这些都得懂行的人来判断。AI 能帮你把活干快,但**判断行不行、对不对味,还是得专业的人**。所以它的定位很清楚:**帮懂行的同事提速,不是替代专业。**
|
||||
|
||||
那它实在的价值在哪?
|
||||
|
||||
**第一,把人从重复体力活里捞出来。**
|
||||
下载、转写、翻译、抠画面、出初版图、拼片——这些又重复又零碎的活儿,原来要好几个环节来回对接,现在机器自己跑。懂行的同事不用再耗在搬运上,精力直接花在判断和打磨上。
|
||||
|
||||
**第二,起步快,不用从零。**
|
||||
你不用对着空白发愁,机器先给你一个站在验证过爆款上的初稿,你是在初稿上改、往上提,不是从 0 开始爬。改一版,比从头做一版,快太多了。
|
||||
|
||||
**第三,能快速放量、快速试。**
|
||||
出初稿快,同样的人力可以同时试更多条、更多方向,再快速筛。爆款本来就有运气成分,**多试快筛**,跑出来的概率自然高。
|
||||
|
||||
但说到底,**这些"省时间、起步快、能放量"都只是手段,不是目的。** 我们做这个工具,最终就盯一件事——**达到宣传效果、把流量引进来、最后能正向盈利。** 一条片子做得再快、再好看,如果不引流、不带转化,那也是白做。所以衡量它有没有用,不看"做得多漂亮",看**最后有没有真的帮我们多卖货、多赚钱。**
|
||||
|
||||
---
|
||||
|
||||
## ④ 未来方向:后面想往哪走(约 6 分钟)
|
||||
|
||||
现在能用了,但还有不少要做的。方向其实很明确:
|
||||
|
||||
**一是朝"全自动化"走。**
|
||||
现在链路还需要人在中间盯几个环节。我们想把它越做越顺,最终做到——**机器一路把初稿跑完,人只在最后审核结果**。前面全自动,人只管"过"还是"不过"、哪里要改。
|
||||
|
||||
**二是三个分块各自做得更准、更像。**
|
||||
- 文案:更精准、产品穿插更自然,不生硬;
|
||||
- 画面:复刻得更和谐、更不排斥,人物和产品更稳定;
|
||||
- 声音:节奏卡得更准。
|
||||
这三块每提升一点,初稿的分数就往上走,人要改的就越少。
|
||||
|
||||
**三是攒 SKG 自己的素材和模板。**
|
||||
做得越多,沉淀下来的好文案、好画面、好节奏就越多。以后再复刻,可以直接调我们自己的库,越用越快、越用越像 SKG 的味道。
|
||||
|
||||
总的方向就一句:**让前面越来越自动,人越来越只做最后那道把关。**
|
||||
|
||||
---
|
||||
|
||||
## 收尾(约 1 分钟)
|
||||
|
||||
好,今天就讲这么多。
|
||||
|
||||
再捋一遍:爆款已经被市场验证过,它靠的是**声音、文案、画面**三样;我们做的,就是**站在爆款上,快速把这三样复刻成相似版、换上我们的产品,做出一个 60 分的初稿**;机器干累活、人干判断;以后越来越自动,人只管最后审核。
|
||||
|
||||
它不完美,还需要人把关,但它能让我们**快很多**。欢迎大家会后来试,也欢迎拍砖提需求。
|
||||
|
||||
谢谢大家。(留 Q&A)
|
||||
|
||||
---
|
||||
|
||||
### 时间分配速查
|
||||
| 段落 | 时长 |
|
||||
|---|---|
|
||||
| 开场 | 1 min |
|
||||
| ① 背景 | 6 min |
|
||||
| ② 场景(含演示) | 8 min |
|
||||
| ③ 价值 | 7 min |
|
||||
| ④ 未来 | 6 min |
|
||||
| 收尾 + Q&A | 2 min |
|
||||
| 合计 | 约 30 min |
|
||||
|
||||
### 上台小提醒
|
||||
- 全程的"老实话"是这次分享的底气:**不吹一键爆款,只讲"快速到 60 分 + 人工把关"**,业务同事反而更信。
|
||||
- ② 这段尽量**现场演示**,看一次实际操作比讲十句都管用;演示不了就放录屏。
|
||||
- 有真实案例就插在 ③ 里讲,比讲道理有说服力。
|
||||
- 别念稿,扫一眼要点抬头讲,括号里的提醒别念出来。
|
||||
Reference in New Issue
Block a user