171 Commits

Author SHA1 Message Date
04a822ac79 feat: add feishu multi-user auth 2026-05-24 00:31:06 +08:00
90dde14ac3 chore: ignore local verification artifacts 2026-05-24 00:00:58 +08:00
3146266383 feat: redesign creative studio entry 2026-05-23 23:55:05 +08:00
0e55945352 auto-save 2026-05-23 23:45 (~2) 2026-05-23 23:45:06 +08:00
d551c45006 auto-save 2026-05-23 23:36 (~2) 2026-05-23 23:39:41 +08:00
0d5c32630f docs: record multilingual asr deployment 2026-05-22 09:30:59 +08:00
7ae92ae4d9 auto-save 2026-05-22 09:27 (~2) 2026-05-22 09:27:54 +08:00
642793500f fix: support multilingual audio transcription 2026-05-22 09:26:59 +08:00
eb4347a843 fix: clarify storyboard videos auto-save 2026-05-21 17:44:40 +08:00
4efb2ce456 fix: make storyboard video clicks previewable 2026-05-21 17:29:16 +08:00
cc12d7c6a7 auto-save 2026-05-21 17:16 (~2) 2026-05-21 17:16:54 +08:00
77d23a06b3 feat: add one-click agent cut terminal 2026-05-21 16:14:58 +08:00
775ad79661 auto-save 2026-05-21 16:05 (~2) 2026-05-21 16:05:52 +08:00
a3ddb05424 auto-save 2026-05-21 15:43 (+1, ~4) 2026-05-21 15:43:13 +08:00
02a9999d8c auto-save 2026-05-21 15:25 (~2) 2026-05-21 15:26:00 +08:00
b6fec10371 auto-save 2026-05-21 15:20 (~2) 2026-05-21 15:20:33 +08:00
7bb4f3ea9f auto-save 2026-05-21 15:15 (+2, ~3) 2026-05-21 15:15:07 +08:00
b82dad4aa8 auto-save 2026-05-21 15:09 (+1, ~3) 2026-05-21 15:09:40 +08:00
68ecc8b97b docs: record production deploy for workbench header 2026-05-21 15:06:46 +08:00
8458dac4bf fix: update workbench header copy 2026-05-21 13:36:40 +08:00
5c47ea37c9 fix: stretch workbench rail height 2026-05-21 13:10:10 +08:00
36da23beb2 fix: refine glass workbench rail and theme colors 2026-05-21 12:37:27 +08:00
a48c2965d9 fix: extend transcript timeline height 2026-05-21 12:10:10 +08:00
d83e56169d fix: make material intake a rail drawer 2026-05-21 11:41:36 +08:00
8421af2af8 fix: place material intake inside rail strip 2026-05-21 11:17:50 +08:00
f2655e1418 fix: nest material intake inside workbench rail 2026-05-21 11:04:35 +08:00
def4900c1d feat: move material intake into sidebar 2026-05-21 10:51:24 +08:00
c805012adc feat: restyle workbench with glassmorphism shell 2026-05-21 10:37:54 +08:00
536b4d7f59 auto-save 2026-05-21 02:09 (~2) 2026-05-21 02:09:07 +08:00
1f193e95f3 docs: record fluid board layout deployment 2026-05-20 23:20:23 +08:00
6597db312b fix: remove fixed board canvas scaling 2026-05-20 23:10:03 +08:00
dbedabaae4 docs: record flexible conversion layout deployment 2026-05-20 22:56:47 +08:00
2b842fd106 fix: remove fixed conversion layer height 2026-05-20 22:51:53 +08:00
9c05e0bd6e docs: record composer generation deployment 2026-05-20 22:44:53 +08:00
ab31a98383 fix: move generation confirm into composer 2026-05-20 22:42:08 +08:00
39b9d211aa docs: record conversion layer deployment 2026-05-20 22:34:40 +08:00
215987aab6 fix: lengthen conversion layer 2026-05-20 22:28:25 +08:00
af7622586c docs: record enlarged composer deployment 2026-05-20 22:24:35 +08:00
e1e9bf8ca1 fix: enlarge generation composer controls 2026-05-20 22:21:32 +08:00
fc06816483 docs: record simplified composer deployment 2026-05-20 22:10:37 +08:00
45b25d01f1 fix: simplify generation composer 2026-05-20 22:04:27 +08:00
06f3eb0504 docs: record stretched conversion deployment 2026-05-20 21:59:03 +08:00
54f159b6ef fix: stretch conversion layer height 2026-05-20 21:51:43 +08:00
f1137cc006 docs: record conversion prompt deployment 2026-05-20 21:47:31 +08:00
d1e2b1785e fix: simplify conversion prompt confirmation 2026-05-20 21:45:17 +08:00
41e71d3865 docs: record fixed source workspace deployment 2026-05-20 21:33:53 +08:00
caa7b730a6 fix: remove source workspace layout tuning 2026-05-20 21:27:19 +08:00
d03b38d75a docs: record layout tuning deployment 2026-05-20 21:19:49 +08:00
0db265f086 feat: add source workspace layout tuning 2026-05-20 21:14:23 +08:00
97cca8d855 docs: record subject result layout deployment 2026-05-20 20:50:57 +08:00
5bffd6308e fix: move subject results below conversion layer 2026-05-20 20:39:28 +08:00
f0f2203eed docs: record centered workbench deployment 2026-05-20 20:20:49 +08:00
f0f567bc16 fix: center scaled workbench vertically 2026-05-20 20:09:39 +08:00
b4f56122f2 docs: record common size layout deployment 2026-05-20 19:51:09 +08:00
3e7c1653f4 fix: snap workbench scale to common sizes 2026-05-20 19:47:26 +08:00
5b13a5cdfc docs: record collapsed agent confirmation deployment 2026-05-20 19:36:21 +08:00
e33463e99f fix: collapse subject agent confirmations 2026-05-20 19:33:51 +08:00
ddaa795c90 docs: record sharp text layout deployment 2026-05-20 19:20:47 +08:00
f35bfe0c5b fix: render scaled workbench text sharply 2026-05-20 19:07:06 +08:00
4a22ca0948 docs: record width-first layout deployment 2026-05-20 19:01:07 +08:00
1d0a77b46a fix: prefer width-first workbench scaling 2026-05-20 18:58:31 +08:00
ae0526955f docs: record scaled layout deployment 2026-05-20 18:45:24 +08:00
54eaac0de2 fix: scale workbench frame to viewport 2026-05-20 18:45:00 +08:00
9f57cbace1 docs: record stable layout deployment 2026-05-20 18:29:49 +08:00
64fef5ae60 fix: stabilize workbench layout frame 2026-05-20 18:29:12 +08:00
2ce2d3ec2e docs: record composer hint deployment 2026-05-20 18:03:02 +08:00
40f1f284d3 fix: simplify conversion composer hints 2026-05-20 17:59:15 +08:00
f495e8384b docs: record render hotfix deployment 2026-05-20 17:52:26 +08:00
2c0e8a058d fix: restore conversion board render 2026-05-20 17:48:55 +08:00
ab78385633 docs: record trait chip deployment 2026-05-20 17:29:10 +08:00
5bdde89809 fix: make conversion trait chips instant 2026-05-20 17:20:41 +08:00
fc3e64d32a docs: record conversion composer control deployment 2026-05-20 17:09:06 +08:00
10d955c6f2 feat: simplify subject conversion composer 2026-05-20 17:07:12 +08:00
eeb7186d7a auto-save 2026-05-20 17:00 (~2) 2026-05-20 17:00:53 +08:00
659ee10efb docs: record conversion composer deployment 2026-05-20 16:56:55 +08:00
b9c5511128 feat: improve subject conversion composer 2026-05-20 16:52:31 +08:00
5ac48749df auto-save 2026-05-20 16:28 (~2) 2026-05-20 16:28:16 +08:00
c43c707cde auto-save 2026-05-20 16:22 (+1, ~2) 2026-05-20 16:22:51 +08:00
85d365069b chore: add safe production deploy script 2026-05-20 16:15:33 +08:00
1618ac13f1 docs: record confirmed conversion deployment 2026-05-20 15:46:24 +08:00
7e763cf51b feat: add confirmed subject conversion flow 2026-05-20 15:27:57 +08:00
fd676c71f9 docs: record cleared conversion deployment 2026-05-20 14:21:28 +08:00
f1c710edc2 fix: clear subject conversion panel 2026-05-20 14:16:58 +08:00
52b839c614 docs: record dialog conversion deployment 2026-05-20 13:59:20 +08:00
d82175f0f3 feat: make subject conversion dialog-driven 2026-05-20 13:53:19 +08:00
b3cc0aa83c docs: record subject agent deployment 2026-05-20 13:11:10 +08:00
e10b1a6e1a fix: quiet subject agent model warnings 2026-05-20 13:07:58 +08:00
83b151df04 docs: record subject agent direction 2026-05-20 13:03:41 +08:00
bc46291afd chore: update worklog 2026-05-20 12:52:31 +08:00
35fc088375 feat: add subject image agent workflow 2026-05-20 12:51:02 +08:00
3d198b024b docs: record subject reference deployment 2026-05-20 11:46:04 +08:00
5f37dd98df auto-save 2026-05-20 11:43 (~2) 2026-05-20 11:43:57 +08:00
7acbfd5214 fix: isolate subject reference generation 2026-05-20 11:39:33 +08:00
e64bf40267 docs: record subject pack deployment 2026-05-20 09:55:45 +08:00
2366662d33 fix: lock subject packs and upscale assets 2026-05-20 09:52:03 +08:00
75666f151f docs: record subject model deployment 2026-05-20 09:21:21 +08:00
c245bff4b8 feat: add subject image model controls 2026-05-20 09:16:28 +08:00
b4a7968c1b docs: record Gemini fallback deployment 2026-05-20 00:00:46 +08:00
3756259850 feat: add Gemini image fallback circuit breaker 2026-05-19 23:56:20 +08:00
516d99ba8c docs: record image timeout deployment 2026-05-19 22:03:11 +08:00
fd794e3e30 fix: fail fast on gpt-image-2 timeouts 2026-05-19 21:58:59 +08:00
00df9d01fe feat: stream subject packs by generation batch 2026-05-19 21:31:47 +08:00
47299396dc fix: require manual subject reconstruction generation 2026-05-19 20:59:22 +08:00
39ab443389 docs: update deployment facts for reconstruction layer 2026-05-19 20:41:37 +08:00
15c6f4d2fc feat: simplify subject reconstruction layer 2026-05-19 20:39:15 +08:00
aabddef486 fix: improve filmstrip picking and audio retry 2026-05-19 20:01:45 +08:00
fe60d5dc99 fix: shrink reference frame thumbnails 2026-05-19 19:46:17 +08:00
bdadd71bcf fix: preserve reference frame aspect ratio 2026-05-19 19:36:38 +08:00
a5979bb0d7 fix: refine reference frame previews 2026-05-19 19:31:45 +08:00
b9bf50f851 feat: add source subject conversion pipeline 2026-05-19 19:23:01 +08:00
ce5f3b448d fix: move transcript timeline below video 2026-05-19 18:43:54 +08:00
7604ed1dfe fix: lift filmstrip hover preview 2026-05-19 18:29:12 +08:00
f574ab4775 fix: refine waveform filmstrip controls 2026-05-19 18:16:57 +08:00
818d785d14 fix: enlarge filmstrip hover near waveform 2026-05-19 18:08:17 +08:00
97016dcc0f fix: hide waveform filmstrip labels 2026-05-19 17:59:58 +08:00
883e1d4de6 fix: align filmstrip frames with waveform 2026-05-19 17:53:17 +08:00
cb991e7a17 fix: enlarge filmstrip frames in place 2026-05-19 17:42:02 +08:00
d461c65472 fix: lift filmstrip preview above frame 2026-05-19 17:33:37 +08:00
1f6eabc819 fix: add temporary filmstrip frame picker 2026-05-19 17:27:09 +08:00
7a7a3b5e19 fix: narrow transcript timeline panel 2026-05-19 16:54:51 +08:00
4f74fa8364 fix: rename source video section title 2026-05-19 16:45:56 +08:00
4753d5e8bf fix: hide audio analysis summary cards 2026-05-19 16:39:52 +08:00
45395cfa3e docs: update deployed status strip change 2026-05-19 16:30:11 +08:00
a9171e225a fix: hide storyboard status strips 2026-05-19 16:27:49 +08:00
b9ba8113f0 revert: restore storyboard intake workflow 2026-05-19 16:17:58 +08:00
a7aa280330 docs: update production intake workflow 2026-05-19 15:49:26 +08:00
54979bc4e2 feat: make intake auto extract copy 2026-05-19 15:48:27 +08:00
e03c5db3fd fix: isolate storyboard videos by row 2026-05-19 15:24:30 +08:00
64a9673fa1 feat: streamline storyboard video rows 2026-05-19 15:11:08 +08:00
980d252815 fix: use cloud local asr fallback 2026-05-19 14:23:20 +08:00
68ab3dab96 fix: label deleted reference frames correctly 2026-05-19 13:57:44 +08:00
e6d957fcab fix: clarify storyboard video candidate generation 2026-05-19 13:50:46 +08:00
ce4ff74b7d chore: add production docker smoke check 2026-05-19 12:37:49 +08:00
f576875af2 fix: prevent dev env leaking into api image 2026-05-19 12:03:50 +08:00
b5855fd457 fix: compact storyboard card sections 2026-05-19 11:29:21 +08:00
6d950ef900 docs: record storyboard card deployment 2026-05-19 11:14:21 +08:00
f4d456cef0 docs: update storyboard card workflow rules 2026-05-19 11:07:30 +08:00
3462758585 feat: simplify storyboard video card flow 2026-05-19 11:05:57 +08:00
ff7bf00f6d fix: force azure asr mode 2026-05-19 10:31:31 +08:00
5b44d35316 fix: add cloud local asr fallback 2026-05-19 10:06:41 +08:00
2954e58740 fix: configure dedicated asr upload gateway 2026-05-19 09:38:17 +08:00
9a4268281e fix: enforce asr client timeout 2026-05-19 09:27:47 +08:00
e5652c463c fix: allow yt-dlp to update production cookies 2026-05-19 09:17:36 +08:00
2fe3db8f88 chore: mount tiktok cookies in production 2026-05-19 09:10:04 +08:00
49c998f66c auto-save 2026-05-19 08:46 (~3) 2026-05-19 08:46:28 +08:00
3b7a06272d chore: persist resource libraries in production 2026-05-18 21:18:54 +08:00
e6004b3bb5 docs: document global resource library 2026-05-18 21:14:38 +08:00
33b91a2b3e auto-save 2026-05-18 21:08 (+2, ~4) 2026-05-18 21:08:58 +08:00
73e8ffecc6 auto-save 2026-05-18 21:03 (+1, ~3) 2026-05-18 21:03:11 +08:00
32620af91d auto-save 2026-05-18 20:57 (~3) 2026-05-18 20:57:23 +08:00
58fe17c5e0 auto-save 2026-05-18 20:51 (~2) 2026-05-18 20:51:56 +08:00
2e2998c5df docs: document skg brand workbench theme 2026-05-18 20:23:21 +08:00
b886e02746 auto-save 2026-05-18 20:19 (~2) 2026-05-18 20:19:24 +08:00
69bb692a58 auto-save 2026-05-18 20:13 (~3) 2026-05-18 20:13:59 +08:00
bc0b010def feat: enforce english prompt language strategy 2026-05-18 20:07:11 +08:00
adf8b2ba0a feat: use subject brief for endpoint frames 2026-05-18 19:33:16 +08:00
33c3aef669 feat: add subject profile controls 2026-05-18 18:38:10 +08:00
87ffa6bac7 docs: align legacy model descriptions 2026-05-18 18:20:48 +08:00
06bf0ee57f auto-save 2026-05-18 18:18 (~2) 2026-05-18 18:18:23 +08:00
095c6f1c00 fix: surface resilient subject asset generation 2026-05-18 18:15:45 +08:00
cc4c021074 chore: record latest worklog 2026-05-18 17:50:39 +08:00
e19e5db595 fix: retry legacy tiktok jobs 2026-05-18 17:46:52 +08:00
92f04f1a7e auto-save 2026-05-18 17:45 (~3) 2026-05-18 17:45:54 +08:00
1f600ae436 feat: improve subject generation workflow 2026-05-18 17:44:52 +08:00
78bd294d57 style: add board light mode 2026-05-18 16:51:34 +08:00
cdffc4ba08 style: align board with login visual system 2026-05-18 16:26:08 +08:00
48d4002cbd feat: connect subject template library 2026-05-18 15:59:56 +08:00
d9b51348fe feat: simplify keyframe selection pool 2026-05-18 15:26:23 +08:00
c22bee4878 feat: move keyframes beside transcript timeline 2026-05-18 15:09:49 +08:00
4991526bcc feat: refine source video keyframe layout 2026-05-18 14:51:44 +08:00
665a0efca6 feat: clarify ad recreation workflow steps 2026-05-18 14:38:21 +08:00
38 changed files with 16785 additions and 5681 deletions

View File

@@ -13,6 +13,9 @@ web/.next
web/out
api/.venv
api/.env
api/.env.local
api/.env.production
api/jobs
jobs
data

8
.gitignore vendored
View File

@@ -16,10 +16,18 @@ __pycache__/
.pids/
deploy/.env.production
deploy/.htpasswd
secrets/
.backups/
# api
api/.venv/
api/jobs/
asset_library/*
!asset_library/.gitkeep
prompt_library/*
!prompt_library/.gitkeep
_trash/
output/
# web
web/.next/

View File

@@ -1,6 +1,6 @@
# 项目接力
- 生成时间May 16, 2026 at 16:38
- 生成时间May 23, 2026 at 23:37
- 项目SKG Marketing Studio / SKG 营销内容工作台
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
@@ -8,78 +8,123 @@
## 最近助手会话概览
- Claudebe53a031-9311-4ee8-b822-d4cfb4f5e78b · 时间未知
- Codex019e2274-9db5-71c1-85e7-2bb0519c8ba9 · 时间未知
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
- Session IDbe53a031-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 IDa9e0449c-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 ID019e2274-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 ID019e4d33-ad65-7673-934e-815226984ea6
- Transcript/Users/kangwan/.codex/sessions/2026/05/22/rollout-2026-05-22T09-01-32-019e4d33-ad65-7673-934e-815226984ea6.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 已经显示,和“营销内容工作台”也水平对齐了
>
> 你浏览器那边如果还看不到,强刷一下页面缓存即可。
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说
> 创建好了,生产环境现在可用:
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本。
>
> ```text
> 登录地址https://marketing.skg.com/login/
> 账号skg
> 密码c413cdc5bbbf2ca042
> ```
> 当前链路是:
>
> 密码备份位置按项目规则放在服务器:`/root/skg-marketing-studio-login.txt`。前面已验证登录接口返回成功
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
> 3. 每行会先自动套默认三字段:
> - `文案`
> - `场景一句话`
> - `人物 + 产品 + 动作`
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta不是每句都先让大模型深度理解原文后重写。
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
>
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
>
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
## Cursor 最近会话
@@ -87,9 +132,9 @@
## 当前仓库状态
- 当前分支:HEAD
- 未提交变更:2
- 最近提交:auto-save 2026-05-16 10:20 (~1)
- 当前分支:main
- 未提交变更:1
- 最近提交:docs: record multilingual asr deployment
- 变更文件:
- M .memory/worklog.json

View File

@@ -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 # ⚠️ 端点 404ASR 还没真跑通
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-proi2i 已通
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 合到 transcriptvideogen 和 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 节点 DAGinput → 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[] + activeJobId8 节点 LAYOUT
- `web/components/dashboard.tsx` — sidebar + drawer + 9 个 Kanban sectioninput/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 seek5 张 < 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 keyDeepgram / 讯飞 / 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-18TikTok 受限链接支持 cookies 配置和失败素材“重新下载”。
- 2026-05-18媒体素材交互统一收口到 `MediaAssetTile`
- 2026-05-18产品图视角识别和产品缺角度补图收敛到 `gpt-image-2`

File diff suppressed because it is too large Load Diff

View File

@@ -1,96 +1,107 @@
{
"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" : "生产网页登录备用账号;飞书免登录为主路径,备用账号密码只放服务器 \/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" : "飞书免登录 OAuth 应用配置App ID 和 App Secret 只放服务器 deploy\/.env.production本地开发放 api\/.env不入库回调地址为 https:\/\/marketing.skg.com\/api\/auth\/feishu\/callback",
"name" : "FEISHU_OAUTH",
"storage" : "api\/.env \/ deploy\/.env.production \/ 飞书开放平台",
"type" : "oauth_app"
}
],
"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 营销内容工作台",
"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 营销内容工作台",
"password" : "",
"url" : "https:\/\/marketing.skg.com\/login\/",
"username" : "飞书免登录;备用账号见 credentials.WEB_LOGIN"
},
"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"
}
}

View File

@@ -1,4 +1,4 @@
# SKG AI 素材管线 - TK 二创验证 Agent Rules
# SKG 营销内容工作台 Agent Rules
## Must Read First

View File

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

104
RULES.md
View File

@@ -1,4 +1,4 @@
# SKG AI 素材管线 - TK 二创验证
# SKG 营销内容工作台
## 启动
- 后台启动(不弹 Terminal`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291日志写入 `.logs/`
@@ -11,27 +11,70 @@
- 详见 `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.342048x1060 保留左右 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.2222048x1060 这类高度不足场景通过纵向滚动承接,不再为了完整高度留下左右空白,浏览器 `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`
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
- 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`
- 登录凭证:生产入口以飞书免登录为主;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。原账号密码登录保留为备用入口,用户名写下方快捷登录,密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt``WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务只对备用账号可见,飞书用户互不可见。
- 禁止手动裸 `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/`
- 用户名:`skg`
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明当前是生产入口应用内登录页数据库密码、API Key、服务器 root 密码不要写这里
- 主路径:飞书免登录
- 备用用户名:`skg`
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
## 元数据回写清单
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
@@ -50,29 +93,44 @@
- 能联网和鉴权时必须 `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 只放服务器环境变量,不入库
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。
- `FEISHU_REDIRECT_URI`:飞书 OAuth 回调地址,生产固定为 `https://marketing.skg.com/api/auth/feishu/callback`
- `FEISHU_OAUTH_SCOPE`:飞书 OAuth 授权范围;默认空值,按飞书应用后台已开权限执行。
- `FEISHU_ALLOWED_EMAIL_DOMAINS` / `FEISHU_ALLOWED_EMAILS` / `FEISHU_ALLOWED_TENANT_KEYS`:可选飞书账号白名单;留空时由飞书应用可见范围控制。
- `AUTH_DATA_ISOLATION_ENABLED`:多用户数据隔离开关,生产保持 `true`;新建 `Job` / `AgentRun` 会写入当前登录用户 owner列表和详情访问只返回本人数据。
- `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`
## 规则
- 不允许编造不存在的部署域名、账号、密码

View File

@@ -8,6 +8,16 @@ WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=false
AUTH_DATA_ISOLATION_ENABLED=true
# 飞书免登录OAuth。生产回调地址需同步配置到飞书开放平台应用安全设置。
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# 模型分工
ASR_MODEL=whisper-1
@@ -17,16 +27,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 +54,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 +62,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

View File

@@ -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 / 翻译 / 文案改写 / 视觉 briefASR 默认自动识别中文、英文和其他多语言,远端失败后先走容器内多语言 `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` 依次尝试语音路径)

View File

@@ -0,0 +1,3 @@
{
"items": []
}

View File

@@ -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 @@
]
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
{
"items": []
}

View File

@@ -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
View File

View 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
@@ -13,29 +15,64 @@ WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=true
AUTH_DATA_ISOLATION_ENABLED=true
# Feishu OAuth login. Register this callback in the Feishu developer console:
# https://marketing.skg.com/api/auth/feishu/callback
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# SKG AI gateway, OpenAI-compatible
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 +80,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

View File

@@ -20,6 +20,20 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/auth/ {
proxy_pass http://skg-marketing-api:4291/auth/;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 60s;
}
location = /api/auth/login {
proxy_pass http://skg-marketing-api:4291/auth/login;
proxy_http_version 1.1;

View File

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

View 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
View File

71
scripts/deploy-prod-safe.sh Executable file
View 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
View 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
View 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>
)
}

View File

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

View File

@@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({
})
export const metadata: Metadata = {
title: "SKG TK 二创工作台",
description: "SKG AI 素材生产管线 · 节点工作流",
title: "SKG 营销内容工作台",
description: "SKG AI 图片、视频和文案创作台",
}
export default function RootLayout({

View File

@@ -4,6 +4,7 @@ import type { FormEvent } from "react"
import { useEffect, useMemo, useState } from "react"
import {
ArrowRight,
Building2,
CheckCircle2,
Eye,
EyeOff,
@@ -14,8 +15,14 @@ import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/l
import { OasisCanvas } from "@/components/login/oasis-canvas"
type LoginStatus = "idle" | "loading" | "success"
type AuthConfig = {
auth_configured?: boolean
password_enabled?: boolean
feishu_enabled?: boolean
}
export default function LoginPage() {
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [remember, setRemember] = useState(true)
@@ -25,6 +32,21 @@ export default function LoginPage() {
const [status, setStatus] = useState<LoginStatus>("idle")
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
useEffect(() => {
let cancelled = false
fetch("/api/auth/config", { cache: "no-store", credentials: "include" })
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (!cancelled && data) setAuthConfig(data)
})
.catch(() => {
if (!cancelled) setAuthConfig(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const onPointerMove = (event: PointerEvent) => {
const centerX = window.innerWidth / 2
@@ -38,6 +60,8 @@ export default function LoginPage() {
}, [])
const disabled = status === "loading" || status === "success"
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
const passwordEnabled = authConfig?.password_enabled ?? true
const mood: LoginCharacterMood = useMemo(() => {
if (status === "success") return "success"
@@ -50,6 +74,7 @@ export default function LoginPage() {
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setHasError(false)
if (!passwordEnabled) return
if (!username.trim() || !password) {
setHasError(true)
return
@@ -75,6 +100,11 @@ export default function LoginPage() {
}
}
function onFeishuLogin() {
setStatus("loading")
window.location.href = "/api/auth/feishu/start?next=/"
}
return (
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
<OasisCanvas />
@@ -89,7 +119,29 @@ export default function LoginPage() {
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
</div>
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
<div className="space-y-3">
{feishuEnabled ? (
<button
className="mb-3 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="button"
disabled={disabled}
onClick={onFeishuLogin}
>
<Building2 className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</button>
) : null}
{feishuEnabled && passwordEnabled ? (
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
<span className="h-px flex-1 bg-white/10" />
<span></span>
<span className="h-px flex-1 bg-white/10" />
</div>
) : null}
{passwordEnabled ? (
<div className="space-y-3">
<label className="block">
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
<UserRound className="h-4 w-4 text-white/45" />
@@ -135,9 +187,11 @@ export default function LoginPage() {
</button>
</span>
</label>
</div>
</div>
) : null}
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
{passwordEnabled ? (
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
<input
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
type="checkbox"
@@ -146,7 +200,8 @@ export default function LoginPage() {
onChange={(event) => setRemember(event.target.checked)}
/>
<span></span>
</label>
</label>
) : null}
{status === "success" ? (
<div className="mt-3">
@@ -156,13 +211,15 @@ export default function LoginPage() {
</div>
) : null}
<button
{passwordEnabled ? (
<button
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="submit"
disabled={disabled}
>
<ArrowRight className="h-4 w-4" />
</button>
</button>
) : null}
</form>
</section>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View 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>
)
}

View File

@@ -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
@@ -201,6 +316,54 @@ export async function getRuntimeHealth(): Promise<RuntimeHealth> {
return res.json()
}
export interface CreativeCopyVariant {
title: string
hook_zh: string
script_zh: string
script_en: string
image_prompt_en: string
video_prompt_en: string
caption_zh: string
hashtags: string[]
}
export interface CreativeCopyResult {
model: string
variants: CreativeCopyVariant[]
}
export async function generateCreativeCopy(body: {
goal: string
product?: string
audience?: string
platform?: string
tone?: string
seconds?: number
source_text?: string
}): Promise<CreativeCopyResult> {
const res = await fetch(`${API_BASE}/creative/copy`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw apiError("generateCreativeCopy", res.status, txt)
}
return res.json()
}
export async function createCreativeImageJob(file?: File | null): Promise<Job> {
const fd = new FormData()
if (file) fd.append("file", file)
const res = await fetch(`${API_BASE}/creative/jobs/image`, { method: "POST", body: fd })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw apiError("createCreativeImageJob", res.status, txt)
}
return res.json()
}
// 把 ImageRef 解析成可显示的 src URL
export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
if (ref.kind === "keyframe") {
@@ -209,6 +372,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 +428,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 +525,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 +759,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 +815,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 +906,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 +959,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
@@ -574,6 +1001,11 @@ export interface ProductRefStateItem {
export interface Job {
id: string
url: string
owner_id?: string
owner_name?: string
owner_email?: string
owner_provider?: string
tenant_key?: string
status: JobStatus
progress: number
message?: string
@@ -588,6 +1020,7 @@ export interface Job {
storyboard_images?: StoryboardImage[]
generated_videos?: GeneratedVideo[]
product_refs?: ProductRefStateItem[]
subject_agent?: SubjectAgentState
error?: string
}
@@ -595,15 +1028,18 @@ export interface BackendHealth {
ok: boolean
llm_configured: boolean
auth_configured?: boolean
auth_modes?: {
password?: boolean
feishu?: boolean
data_isolation?: boolean
}
base_url: string
models?: {
asr?: string
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 +1053,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 +1088,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)
@@ -665,6 +1129,9 @@ export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }
export interface JobSummary {
id: string
url: string
owner_name?: string
owner_email?: string
owner_provider?: string
status: JobStatus
progress: number
message: string
@@ -717,6 +1184,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 +1348,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 +1528,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 +1580,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 +1596,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 +1620,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 +1644,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 +1664,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,

View 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 分 + 人工把关"**,业务同事反而更信。
- ② 这段尽量**现场演示**,看一次实际操作比讲十句都管用;演示不了就放录屏。
- 有真实案例就插在 ③ 里讲,比讲道理有说服力。
- 别念稿,扫一眼要点抬头讲,括号里的提醒别念出来。