Compare commits
23 Commits
1f193e95f3
...
backend-wo
| Author | SHA1 | Date | |
|---|---|---|---|
| c60cb47ee1 | |||
| 061eb7d867 | |||
| 07384c5e19 | |||
| 4280624810 | |||
| 028718df0b | |||
| a6eddf1c14 | |||
| 9e307e307c | |||
| c2e9558f5b | |||
| c626ec51d6 | |||
| 1ac9b1bde3 | |||
| 1c451c6ab3 | |||
| 408c5fca47 | |||
| 2a1aa4c994 | |||
| ebac2e86b5 | |||
| 47653ee319 | |||
| 4d2a4a0299 | |||
| e6387cf7af | |||
| fde94f4698 | |||
| dddf410dcb | |||
| 301ec4fc3b | |||
| 2cfd7de5d5 | |||
| a2897ef2be | |||
| e6a5ea46a6 |
@@ -13,9 +13,6 @@ web/.next
|
||||
web/out
|
||||
|
||||
api/.venv
|
||||
api/.env
|
||||
api/.env.local
|
||||
api/.env.production
|
||||
api/jobs
|
||||
jobs
|
||||
data
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -16,16 +16,10 @@ __pycache__/
|
||||
.pids/
|
||||
deploy/.env.production
|
||||
deploy/.htpasswd
|
||||
secrets/
|
||||
|
||||
# api
|
||||
api/.venv/
|
||||
api/jobs/
|
||||
asset_library/*
|
||||
!asset_library/.gitkeep
|
||||
prompt_library/*
|
||||
!prompt_library/.gitkeep
|
||||
_trash/
|
||||
|
||||
# web
|
||||
web/.next/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 项目接力
|
||||
|
||||
- 生成时间:May 20, 2026 at 16:25
|
||||
- 生成时间:May 16, 2026 at 16:38
|
||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 状态:active
|
||||
@@ -8,111 +8,78 @@
|
||||
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||
- Codex:019e3db1-012e-7163-bc78-acf7cde326e7 · 时间未知
|
||||
- Claude:be53a031-9311-4ee8-b822-d4cfb4f5e78b · 时间未知
|
||||
- Codex:019e2274-9db5-71c1-85e7-2bb0519c8ba9 · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
|
||||
## Claude 最近会话
|
||||
|
||||
- Session ID:a9e0449c-d9cb-4a2a-bb16-16596dfb552a
|
||||
- Transcript:/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/a9e0449c-d9cb-4a2a-bb16-16596dfb552a.jsonl
|
||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 分支:main
|
||||
- Session ID:be53a031-9311-4ee8-b822-d4cfb4f5e78b
|
||||
- Transcript:/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/be53a031-9311-4ee8-b822-d4cfb4f5e78b.jsonl
|
||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/api/jobs
|
||||
- 分支:HEAD
|
||||
- 敏感字段:已对 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 + 紫色加亮:
|
||||
>
|
||||
> 这其实就是你那套讲解稿里的"快速到 60 分,不从零起步"——机器先给一个站在验证过爆款上的初稿,人在 60 分上挑、上改,而不是对着空白发愁。**同一个原则,直接套到转换层。**
|
||||
> | | 浮条 1 (InputNode) | 浮条 2 (VisualLab) |
|
||||
> |---|---|---|
|
||||
> | 可视宽 | 1255 | 620 |
|
||||
> | 内容总宽 | 2498 | 988 |
|
||||
> | 需要横滚 | ✅ | ✅ |
|
||||
>
|
||||
> ## 修正后的定位:Agent 主动提案,需求方只做"选/推"
|
||||
> CSS 改动:所有 `.react-flow__node .overflow-x-auto` 的 webkit scrollbar 高度 14px、轨道半透明白边框、拖动滑块紫色(hover 加深、active 粉紫),最小宽度 48px 保证拖手够大。Firefox 自动用 native scrollbar-color。
|
||||
>
|
||||
> 我上一轮说的"别强制聊天、给个 chip + 自由描述框"——对**知道自己要什么**的人对,对**说不清的人是错的**。删掉。改成:
|
||||
>
|
||||
> ```
|
||||
> ┌─ 转换层 ────────────────────┐
|
||||
> │ [参考帧] AI读图:白色筋膜枪·手持 │
|
||||
> │ │
|
||||
> │ 我建议这 3 个方向(选一个): │ ← agent 主动出,不问
|
||||
> │ ┌─────────────────────────┐ │
|
||||
> │ │ A 原样复刻 · 稳 缩略│ │
|
||||
> │ │ B 换暖木纹高端款 · 提质感 缩略│ │ ← 每个都已是可生成的成品设定
|
||||
> │ │ C 卡通吉祥物版 · 抓眼球 缩略│ │
|
||||
> │ └─────────────────────────┘ │
|
||||
> │ [就用 A 生成 6 视图 →] │ ← 默认动作,零思考一键走
|
||||
> │ 不对?跟我说一句…(可选) │ ← 想拨一下才打字
|
||||
> └───────────────────────────────┘
|
||||
> ↓
|
||||
> 右侧主体元素:6 视图 queued → 逐视角回填
|
||||
> ```
|
||||
>
|
||||
> 核心三点:
|
||||
> - **进帧即出提案**。参考帧一进来,agent 自动读图 + 直接抛 2~3 个**已经成型、点了就能生成**的方向(每个背后都是一份完整主体设定 bible),而不是一个等用户填的空框。
|
||||
> - **最小动作 = 一次点击认可**。说不清没关系,默认有个推荐项,点"就用这个"直接出 6 视图。想微调才说话("再高端点…
|
||||
> 去刷新看。如果还不够明显(被画布 zoom 缩太小),可以再加大到 18-20px,告诉我。
|
||||
|
||||
## Codex 最近会话
|
||||
|
||||
- Session ID:019e3db1-012e-7163-bc78-acf7cde326e7
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/19/rollout-2026-05-19T08-44-30-019e3db1-012e-7163-bc78-acf7cde326e7.jsonl
|
||||
- Session ID:019e2274-9db5-71c1-85e7-2bb0519c8ba9
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/14/rollout-2026-05-14T01-48-48-019e2274-9db5-71c1-85e7-2bb0519c8ba9.jsonl
|
||||
- 工作目录:/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
|
||||
> ```
|
||||
>
|
||||
> `/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证`
|
||||
> 密码备份位置按项目规则放在服务器:`/root/skg-marketing-studio-login.txt`。前面已验证登录接口返回成功。
|
||||
|
||||
## Cursor 最近会话
|
||||
|
||||
@@ -120,9 +87,9 @@
|
||||
|
||||
## 当前仓库状态
|
||||
|
||||
- 当前分支:main
|
||||
- 未提交变更:8 项
|
||||
- 最近提交:docs: record image timeout deployment
|
||||
- 当前分支:HEAD
|
||||
- 未提交变更:2 项
|
||||
- 最近提交:auto-save 2026-05-16 10:20 (~1)
|
||||
- 变更文件:
|
||||
- M .memory/worklog.json
|
||||
|
||||
|
||||
@@ -1,127 +1,87 @@
|
||||
# SKG TK 二创验证 — 当前状态(2026-05-18)
|
||||
|
||||
## 一句话
|
||||
SKG 信息流广告快速复刻工作台:TK 链接/上传 → 下载源视频 → 并行音频解析与 12 张动作/节奏参考帧 → 相似主体 / 产品素材池 → 分镜口播改写 → 首尾帧审核 → 视频候选待开放。当前主流程不直接批量提交视频模型。
|
||||
当前产品方向已收窄为“信息流广告快速复刻”:TK 链接 / 上传视频后,先下载源视频,再并行跑音频文案路和视频视觉路;视频视觉路自动抽 6 张人物定向随机参考帧;产品素材独立成池,自动识别视角并补缺角度;分镜工作台按逐句时间轴写新口播、人物/产品需求和首尾帧规划。当前暂停直接提交视频模型,先逐条生成并审核首帧 / 尾帧。
|
||||
|
||||
## 路径 / 端口
|
||||
- 路径:`~/Projects/business/20260512-20260512-skg-tk-二创验证/`
|
||||
- web dev:`cd web && pnpm dev`(端口 **4290**)
|
||||
- api dev:`cd api && source .venv/bin/activate && uvicorn main:app --port 4291 --reload`
|
||||
- 测试 job:`?job=c6767f3a166b`(chrisorb 71s 竖屏 TK)
|
||||
- 当前工作树:`/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证-backend/`
|
||||
- 主项目路径:`/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/`
|
||||
- 后台启动:`./scripts/start-dev-background.sh`(前端 4290,后端 4291,launchd 托管)
|
||||
- 后台停止:`./scripts/stop-dev-background.sh`
|
||||
- web dev:`cd web && npm run dev`
|
||||
- api dev:`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`
|
||||
- 注意:后端不要带 `--reload` 跑下载、抽帧、音频和生图等长任务。
|
||||
|
||||
## SKG 网关能力(实测 · 关键!)
|
||||
`base_url: https://ai.skg.com/ezlink/v1`
|
||||
key 写在 `api/.env` 的 `LLM_API_KEY`
|
||||
## 当前模型分工
|
||||
`LLM_BASE_URL` 默认走 `https://ai.skg.com/ezlink/v1`,图片同样默认走 `IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1`,语音默认走 `https://ai.skg.com/azure`,生产视频默认走 `https://ai.skg.com/doubao`。
|
||||
|
||||
| 端点 / 字段 | 状态 | 用途 |
|
||||
| 任务 | 当前模型 / 通道 | 备注 |
|
||||
|---|---|---|
|
||||
| 远端 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`。 |
|
||||
| TK 下载 | `yt-dlp` + 可选 cookies | 公开视频裸下载;受限视频可配 `YTDLP_COOKIES_FILE` 或 `YTDLP_COOKIES_FROM_BROWSER`,也可直接上传 MP4。 |
|
||||
| 远端 ASR | `ASR_MODEL=whisper-1` | 失败后进本机 ASR,再进多模态兜底。 |
|
||||
| 本机 ASR | `LOCAL_ASR_MODEL=mlx-community/whisper-tiny` | 默认二级兜底,优先产出真实逐句时间轴。 |
|
||||
| ASR 兜底 / 音频分析 | `ASR_FALLBACK_MODEL=gemini-2.5-flash` | 多模态音频兜底;后端会拒绝假字幕、重复文本和覆盖率过低结果。 |
|
||||
| 字幕翻译 | `TRANSLATE_MODEL=gemini-2.5-flash` | 保留 Gemini。 |
|
||||
| 画面理解 | `VISION_MODEL=gpt-4o` | 关键帧 Vision 已切 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;覆盖关键帧生图、水印清理、元素提取、主体资产包、产品补角度、首尾帧。 |
|
||||
| 新口播改写 | `AUDIO_REWRITE_MODEL=gpt-4o` | 默认跟随 `REWRITE_MODEL`;旧 Gemini 覆盖值会自动归一化。 |
|
||||
| 产品视角识别 | `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`。 |
|
||||
| 视频 | `VIDEO_MODEL=seedance` | 当前主流程暂停直接提交;生产通道默认 `ai.skg.com/doubao`,Seedance 真实 ID 由 `VIDEO_MODEL_SEEDANCE` 配置。 |
|
||||
|
||||
**网关后端 = one-hub 多渠道代理**。当前 key 分组叫「纯OpenAI+AWSClaude+Gemini官方」,缺 audio 渠道(`gpt-4o-audio-preview` 503 "无可用渠道")和 video 渠道。
|
||||
|
||||
## 模型选型(运行时默认 / 归一化后)
|
||||
```
|
||||
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 状态(9 步工作台版)
|
||||
当前主入口是信息流复刻工作表,不再是旧 ReactFlow 八节点主画布。
|
||||
|
||||
| 步 | 节点 | 状态 | 备注 |
|
||||
## 当前主流程
|
||||
| 步 | 模块 | 状态 | 备注 |
|
||||
|---|---|---|---|
|
||||
| 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 架构(重要)
|
||||
- 主入口:`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
|
||||
KeyFrame {
|
||||
index: number // 稳定 ID(不连续!frames 数组按 timestamp 排序)
|
||||
timestamp: number
|
||||
url: string
|
||||
description?: {
|
||||
scene, objects: [{name, position, color, extract_prompt}],
|
||||
style, suggested_prompt
|
||||
}
|
||||
generated_images?: [{ id, prompt, model, mode, url, selected, created_at }]
|
||||
}
|
||||
|
||||
Job { frames: KeyFrame[] ... }
|
||||
```
|
||||
|
||||
**前端取帧必须用 `frames.find(x => x.index === activeIndex)` 不能用数组下标**(之前的 bug)。
|
||||
| 1 | 输入 / 下载 | 已通 | TK 链接或上传视频创建 job,下载完成后进入分析队列。 |
|
||||
| 2 | 音频文案路 | 已通 | 拆 `audio.wav`,ASR、翻译、讲话人 / 节奏 / 背景音分析;结果默认折叠展示。 |
|
||||
| 3 | 视频视觉路 | 已通 | 自动抽 6 张人物定向随机参考帧;当前工作区按 9:16 原视频播放秒数手动补帧。 |
|
||||
| 4 | 相似主体资产 | 已通 | 用关键帧和可选内置角色生成同一主体的 10 张白底视图。 |
|
||||
| 5 | 产品资产池 | 已通 | 上传 / 内置产品图统一入池,自动识别视角、结构点、用途、风险,缺角度可补图。 |
|
||||
| 6 | 分镜工作台 | 已通 | 按逐句时间轴编辑新口播、镜头类型、人物 / 产品开关、首帧 / 尾帧规划。 |
|
||||
| 7 | 首尾帧闸门 | 已通 | 每条分镜先用相似主体视图和产品素材生成首帧 / 尾帧,审核后保存。 |
|
||||
| 8 | 视频候选 | 暂停直提 | 历史候选保留展示;当前不再一键打 Seedance,等首尾帧审核后再开放单条提交。 |
|
||||
|
||||
## 关键文件
|
||||
- `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 所有端点,Job/KeyFrame/AudioScript/ProductRef/SubjectAsset/SceneAsset/GeneratedVideo 模型
|
||||
- `api/main.py` — FastAPI 后端、模型路由、任务状态、ASR/翻译/音频分析、生图、产品识别、首尾帧和视频接口。
|
||||
- `api/database.py` — 后端数据库层;当前用 SQLite 保存 document / job / media asset 元数据,媒体文件仍在 `jobs/<jobId>/`。
|
||||
- `api/.env.example` — 本地模型和网关模板;已包含 `GPT_TEXT_MODEL=gpt-4o`。
|
||||
- `deploy/.env.production.example` — 生产环境模板;视频默认 SKG Doubao / Seedance 网关。
|
||||
- `RULES.md` — 启动、部署事实、模型环境变量和项目规则。
|
||||
- `docs/source-analysis.html` — 源码解析页;任何影响产品理解、接口、模型分工或操作路径的改动都要同步这里。
|
||||
- `web/components/ad-recreation-board.tsx` — 当前信息流复刻主工作台。
|
||||
- `web/components/media-asset-tile.tsx` — 统一媒体素材缩略图、hover 放大、删除和状态遮罩组件。
|
||||
- `web/lib/api.ts` — 前端 API client 和运行模型标注类型。
|
||||
|
||||
## 已通的 API 端点
|
||||
## 主要 API
|
||||
```
|
||||
POST /jobs 创建 job(链接)
|
||||
POST /jobs/{id}/download/retry TK 链接下载失败后重新下载
|
||||
POST /jobs/upload 上传视频
|
||||
GET /jobs/{id} job 状态
|
||||
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
|
||||
GET /documents
|
||||
POST /jobs
|
||||
POST /jobs/{id}/download/retry
|
||||
POST /jobs/upload
|
||||
GET /jobs
|
||||
GET /jobs/{id}
|
||||
DELETE /jobs/{id}
|
||||
POST /jobs/{id}/analyze
|
||||
POST /jobs/{id}/transcribe
|
||||
POST /jobs/{id}/frames?t=<sec>
|
||||
DELETE /jobs/{id}/frames/{idx}
|
||||
POST /jobs/{id}/frames/{idx}/describe
|
||||
POST /jobs/{id}/frames/{idx}/cleanup
|
||||
POST /jobs/{id}/frames/{idx}/cleanup/apply
|
||||
POST /jobs/{id}/frames/{idx}/generate
|
||||
POST /jobs/{id}/frames/{idx}/scene-asset
|
||||
POST /jobs/{id}/frames/{idx}/elements
|
||||
POST /jobs/{id}/frames/{idx}/elements/{element_id}/cutout
|
||||
POST /jobs/{id}/frames/{idx}/elements/{element_id}/subject-assets
|
||||
POST /jobs/{id}/assets
|
||||
PUT /jobs/{id}/product-refs
|
||||
POST /jobs/{id}/assets/product-views/analyze
|
||||
POST /jobs/{id}/assets/product-angle
|
||||
POST /jobs/{id}/script/rewrite
|
||||
PUT /jobs/{id}/frames/{idx}/storyboard
|
||||
POST /jobs/{id}/frames/{idx}/storyboard/video
|
||||
```
|
||||
|
||||
## 当前约束 / 不要踩
|
||||
@@ -130,16 +90,21 @@ GET /health
|
||||
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)`。
|
||||
6. 当前主流程不直接批量提交视频;先走“分镜规划 → 首尾帧 → 人工审核”。
|
||||
7. 产品素材池默认是“同一产品”,不做不同产品身份判断;视角识别必须按佩戴者左 / 右、上 / 下、内 / 外侧描述。
|
||||
8. 自动抽帧默认是 `frames=6` + `target=random_subject` + `quality=accurate` + `mode=replace`;如果需要特定动作或表情,用“当前点抽帧”手动补。
|
||||
9. 文档是顶层业务归类:每个 TK 链接或上传视频默认一个 `document`,`job` 归属到 `document_id`;DB 存元数据和文件索引,视频 / 图片 / 音频文件不进 DB。
|
||||
10. 后端长任务不要用 `--reload`。
|
||||
11. 关键帧 `index` 是稳定 ID,不等于数组下标;前端取帧用 `frames.find(x => x.index === idx)`。
|
||||
12. TikTok cookies 属于账号登录态,只能放本机 / 服务器私有环境;不要提交 cookies 文件或账号密码。
|
||||
|
||||
## 最近变更
|
||||
- 2026-05-18:前端模型链路弹窗、`.project.json`、`api/README.md` 和本状态文档已按真实后端链路重写:音频三级 ASR、翻译失败行为、音频画像兜底、产品识别重试、相似主体 GPT brief + gpt-image-2 文字生图、脚本改写本地模板兜底、视频主入口暂停。
|
||||
- 2026-05-18:TK 链接下载新增 `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER` 支持;受限视频失败时前端提示上传 MP4 或配置后端 cookies 登录态。
|
||||
- 2026-05-18:素材输入端失败任务支持重新下载 / 重新解析;选中失败且无 `video_url` 的 TK 素材时调用后端重试接口,已有视频的失败任务会清掉自动触发标记并重新跑音频/视觉路。
|
||||
- 2026-05-18:清理个人语音通道残留,`/health`、前端类型、环境模板和文档不再暴露相关字段或配置。
|
||||
- 2026-05-18:新增后端数据库层,SQLite 默认落在 `APP_DB_URL` / `DATABASE_URL` 或 `JOBS_DIR/app.db`;`/documents` 返回文档归类列表,`/health.database` 返回 DB 状态。
|
||||
- 2026-05-18:`VISION_MODEL`、`REWRITE_MODEL`、`AUDIO_REWRITE_MODEL` 切到 GPT 默认模型 `gpt-4o`,并加旧 Gemini 环境变量归一化保护。
|
||||
- 2026-05-18:语音通道固定 Azure OpenAI TTS,并按 `AZURE_TTS_PATHS` 尝试语音路径。
|
||||
- 2026-05-18:TikTok 受限链接支持 cookies 配置和失败素材“重新下载”。
|
||||
- 2026-05-18:当前主路径暂停直接提交视频,改为逐条首尾帧闸门。
|
||||
- 2026-05-18:媒体素材交互统一收口到 `MediaAssetTile`。
|
||||
- 2026-05-18:产品图视角识别和产品缺角度补图收敛到 `gpt-image-2`。
|
||||
|
||||
6363
.memory/worklog.json
6363
.memory/worklog.json
File diff suppressed because it is too large
Load Diff
128
.project.json
128
.project.json
@@ -1,96 +1,96 @@
|
||||
{
|
||||
"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" : "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": "MiniMax T2A 配音 API Key,本地开发只放 api/.env 的 MINIMAX_API_KEY,不入库",
|
||||
"name": "MINIMAX_API_KEY",
|
||||
"storage": "api/.env",
|
||||
"type": "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "OpenAI-compatible GPT 图片模型 Key;未单独配置 IMAGE_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env,不入库",
|
||||
"name" : "IMAGE_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
"description": "OpenAI-compatible GPT 图片模型 Key;未单独配置 IMAGE_API_KEY 时复用 LLM_API_KEY,本地开发只放 api/.env,不入库",
|
||||
"name": "IMAGE_API_KEY",
|
||||
"storage": "api/.env / deploy/.env.production",
|
||||
"type": "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "Azure OpenAI 协议语音\/配音 Key;未单独配置 AZURE_OPENAI_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env,不入库",
|
||||
"name" : "AZURE_OPENAI_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
"description": "Azure OpenAI 协议语音/配音 Key;未单独配置 AZURE_OPENAI_API_KEY 时复用 LLM_API_KEY,本地开发只放 api/.env,不入库",
|
||||
"name": "AZURE_OPENAI_API_KEY",
|
||||
"storage": "api/.env / deploy/.env.production",
|
||||
"type": "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "SKG 豆包 \/ Seedance 视频生成 API Key,生产只放服务器 deploy\/.env.production 的 VIDEO_API_KEY,本地开发放 api\/.env,不入库",
|
||||
"name" : "VIDEO_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
"description": "SKG 豆包 / Seedance 视频生成 API Key,生产只放服务器 deploy/.env.production 的 VIDEO_API_KEY,本地开发放 api/.env,不入库",
|
||||
"name": "VIDEO_API_KEY",
|
||||
"storage": "api/.env / deploy/.env.production",
|
||||
"type": "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "生产网页登录;用户名写 RULES.md,密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name" : "WEB_LOGIN",
|
||||
"storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production",
|
||||
"type" : "web_login"
|
||||
"description": "生产网页登录;用户名写 RULES.md,密码只放服务器 /root/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name": "WEB_LOGIN",
|
||||
"storage": "/root/skg-marketing-studio-login.txt / deploy/.env.production",
|
||||
"type": "web_login"
|
||||
}
|
||||
],
|
||||
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
|
||||
"kind" : "app",
|
||||
"name" : "SKG Marketing Studio \/ SKG 营销内容工作台",
|
||||
"ownership" : "company",
|
||||
"pin_order" : 1778664997,
|
||||
"pinned" : true,
|
||||
"ports" : [
|
||||
"description": "SKG 信息流广告快速复刻第一步:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后优先解析原音频,提取原文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜、元素生成和视频合成暂保留为后续能力,不作为当前开始流程的默认动作。",
|
||||
"kind": "app",
|
||||
"name": "SKG Marketing Studio / SKG 营销内容工作台",
|
||||
"ownership": "company",
|
||||
"pin_order": 1778664997,
|
||||
"pinned": true,
|
||||
"ports": [
|
||||
{
|
||||
"fixed" : true,
|
||||
"label" : "web-dev",
|
||||
"port" : 4290
|
||||
"fixed": true,
|
||||
"label": "web-dev",
|
||||
"port": 4290
|
||||
},
|
||||
{
|
||||
"fixed" : true,
|
||||
"label" : "api-dev",
|
||||
"port" : 4291
|
||||
"fixed": true,
|
||||
"label": "api-dev",
|
||||
"port": 4291
|
||||
}
|
||||
],
|
||||
"quick_login" : {
|
||||
"label" : "SKG Marketing Studio \/ SKG 营销内容工作台",
|
||||
"password" : "c413cdc5bbbf2ca042",
|
||||
"url" : "https:\/\/marketing.skg.com",
|
||||
"username" : "skg"
|
||||
"quick_login": {
|
||||
"label": "SKG Marketing Studio / SKG 营销内容工作台",
|
||||
"password": "c413cdc5bbbf2ca042",
|
||||
"url": "https://marketing.skg.com",
|
||||
"username": "skg"
|
||||
},
|
||||
"stack" : [
|
||||
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
|
||||
"stack": [
|
||||
"Next.js + Python(yt-dlp/ffmpeg) + OpenAI-compatible LLM + GPT Image + Azure OpenAI TTS + Seedance"
|
||||
],
|
||||
"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" : "git",
|
||||
"type" : "repo",
|
||||
"url" : "https:\/\/git.kang-kang.com\/kangwan\/20260512-skg-tk"
|
||||
"label": "source-analysis",
|
||||
"type": "docs",
|
||||
"url": "docs/source-analysis.html"
|
||||
},
|
||||
{
|
||||
"label" : "git",
|
||||
"type" : "repo",
|
||||
"url" : "https:\/\/git.kang-kang.com"
|
||||
"type": "repo",
|
||||
"label": "git",
|
||||
"url": "https://git.kang-kang.com/kangwan/20260512-skg-tk"
|
||||
}
|
||||
],
|
||||
"worklog" : {
|
||||
"auto" : true,
|
||||
"path" : "\/Users\/kangwan\/Projects\/business\/20260512-20260512-skg-tk-二创验证\/.memory\/worklog.json"
|
||||
"worklog": {
|
||||
"auto": true,
|
||||
"path": "/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.memory/worklog.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl libgomp1 \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY api/requirements.txt /app/requirements.txt
|
||||
|
||||
72
RULES.md
72
RULES.md
@@ -11,58 +11,21 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
- 当前产品方向(2026-05-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后线性完成抽帧、分镜、元素生成、合成”的旧做法。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取 6 张人物定向随机参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;当前暂停直接调视频模型,先逐条用“相似主体视图 + 产品素材池 + 首尾帧文字规划”生成并审核首帧/尾帧,保存规划后再决定哪些分镜进入单条视频候选。
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
- 发布状态:已部署并验证(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-20):`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]` 和 `max-w-[1920px]`,未再命中旧的 `h-[1000px]`、`w-[1800px]`、`BOARD_SCALE_PRESETS` 或 `boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
|
||||
- 最近部署验证(2026-05-20):`2b842fd` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520145223.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/743b82648dfa9db9.js` 已包含 `h-32`、`maxHeight:560`、`提示词就绪` 和 `确认生成`,且未再命中旧的 `height:640` / `h-40`;对应转换层取消固定长高,生成要求输入区回到 128px,底部仍由发送区主按钮确认生成。
|
||||
- 最近部署验证(2026-05-20):`ab31a98` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520144227.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/5bbecb6cf31316cb.js` 已包含 `h-40`、`提示词就绪` 和 `确认生成`,对应生成要求输入框加高到 160px,出图提示词生成后不再自动弹窗,底部主按钮直接切换为确认生成。
|
||||
- 最近部署验证(2026-05-20):`215987a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142849.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/54e1ee55c5019be8.js` 已包含 `height:640`,对应转换层固定高度从 560px 扩到 640px。
|
||||
- 最近部署验证(2026-05-20):`e1e9bf8` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142145.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测生成要求 composer:文本输入区实际高约 119px,张数控件和发送按钮实际高约 42px,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-expanded.png`。
|
||||
- 最近部署验证(2026-05-20):`45b25d0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520140706.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:生成要求区不再渲染“当前要求”、“提示词已生成”和“对话记录已收起”摘要,保留元素副本也已移除;该区只保留文本输入、张数控件和发送按钮,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-simplified.png`。
|
||||
- 最近部署验证(2026-05-20):`54f159b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520135509.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:转换层和参考帧池共用高度从 500px 拉到 560px,转换层内部改为 gap 堆叠并让主要板块 `shrink-0`,超出由转换层自身滚动承接;页面无客户端异常,验证截图 `/tmp/skg-conversion-stretched.png`。
|
||||
- 最近部署验证(2026-05-20):`d1e2b17` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520134529.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:内嵌“待确认提示词”卡和黑色“确认并生成 N 张”按钮均不再渲染,页面无客户端异常;有待确认 prompt 时只在“生成要求”标题右侧显示小型“待确认 · N 张”入口,验证截图 `/tmp/skg-conversion-no-inline-confirm.png`。
|
||||
- 最近部署验证(2026-05-20):`caa7b73` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520132820.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测:页面无客户端异常,源视频工作区已撤销“布局调节”按钮和 `localStorage["skg-source-workspace-layout:v1"]` 布局读写,固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 270px、参考帧池 140px、转换层 500px 内部滚动、主体空态 78px;验证截图 `/tmp/skg-layout-fixed-no-tuning.png`。
|
||||
- 最近部署验证(2026-05-20):`0db265f` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520131649.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 打开历史任务并展开“布局调节”:面板显示左列宽、视频高、时间轴高、参考池宽、转换层高、主体空态 6 个滑杆,调参值写入 `localStorage["skg-source-workspace-layout:v1"]`,供用户先在线试比例再固化默认值。
|
||||
- 最近部署验证(2026-05-20):`5bffd63` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520123949.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 复测 1440x900 与 2048x1060:历史任务加载后转换层占据主操作宽度,主体元素下移为转换层下方的紧凑结果栏,未再出现右侧三栏挤压;滚动到主体元素位置后仍能看到下方分镜工作台承接。
|
||||
- 最近部署验证(2026-05-20):`f0f567b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520120958.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 截图复测 1440x900、1728x1117、2048x1060、2560x1440:缩放后的工作台在 1440/1728/2560 这类高度有余量的窗口上下居中,2048x1060 保持顶部对齐并承接纵向内容,未出现先前的底部黑边失衡。
|
||||
- 最近部署验证(2026-05-20):`3e7c165` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520114759.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1366x768、1440x900、1728x1117、1920x1080、2048x1060、2200x1400、2560x1440:缩放档位分别为 0.72、0.8、0.92、1.06、1.06、1.16、1.34;2048x1060 保留左右 70px 呼吸感且无横向溢出,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`e33463e` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520113414.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测转换层:页面显示“生成要求”和“对话记录已收起”,不再显示旧标题“生图对话”,也不再渲染“我们将不再强制...”这类模型确认消息;最终英文 prompt 仍保留在“待确认提示词”区域,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`f35bfe0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520111824.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台仍按可见宽度优先铺满且外层 wrapper 左右间隙均为 0;内层画布已改用 CSS `zoom` 渲染,三个窗口分别为 `zoom=0.8/1.138/1.222`,`transform` 均为 `none`,避免整屏 transform 小数缩放造成文字发虚,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`1d0a77b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520105846.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台按可见宽度优先缩放,外层 wrapper 左右间隙均为 0;三个窗口分别缩放到 0.8、1.138、1.222,2048x1060 这类高度不足场景通过纵向滚动承接,不再为了完整高度留下左右空白,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`54eaac0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520104155.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台以 1800x1000 为基准分别缩放到 0.8、1.06、1.222,主网格列宽、源视频区列宽和三栏主体管线列宽保持一致,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`64fef5a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520102354.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 以 1440x900 与 2200x1400 两种窗口复测,工作台内部画布固定为 1800x1000,主网格列宽、源视频区列宽和三栏主体管线列宽一致,浏览器 `pageerror` 为空。
|
||||
- 最近部署验证(2026-05-20):`40f1f28` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520095941.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测首页正常渲染,浏览器 `pageerror` 为空。转换层不再显示固定快捷需求按钮,生图对话空态和输入框改为中性“保留识别元素 / 补充调整要求”,由识别结果 chip 和自然语言对话承接用户意图。
|
||||
- 最近部署验证(2026-05-20):`2c0e8a0` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520094923.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测首页正常渲染到工作台,浏览器 `pageerror` 为空;本次修复 `selectedAgentTraitsDirty` 残留变量名导致的客户端 `ReferenceError`,恢复转换层页面首屏渲染。
|
||||
- 最近部署验证(2026-05-20):`5bdde89` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520092721.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层识别结果 chip 改为本地即时切换:点亮表示保留元素、再次点击取消、清空按钮取消全部;点击 chip 不再触发 `/subject-agent/message`,保留元素随下一条“发送消息”一次性提交,避免每点一个特征都等待模型导致卡顿。
|
||||
- 最近部署验证(2026-05-20):`10d955c` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520090750.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层已移除可见快捷需求 chip,复刻/创新/卡通/人物占比作为对话默认意图写入输入提示;生成张数控件移到发送消息旁边,默认 6 张、当前支持 1-10;参考输入空态和已选参考图缩略图压小并继续复用 `MediaAssetTile` hover 放大预览。
|
||||
- 最近部署验证(2026-05-20):`b9c5511` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520085513.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层已更新为参考输入区支持左侧 `+`、参考帧拖拽、胶片拖拽和本地图片拖入,下方为生图对话消息 composer,右侧主体元素套图输出逻辑保持不变。
|
||||
- 最近部署验证(2026-05-19):`fd794e3` 已推送并部署到 `/opt/skg-marketing-studio`;生产 `/health` 显示 `image=gpt-image-2`、`subject_image=gpt-image-2`、`image_request_timeout_seconds=60`、`image_base_url=https://ai.skg.com/ezlink/v1`。容器内最小文字生图探针在 20 秒限制下返回 `ReadTimeout`,说明当前阻塞点是 `https://ai.skg.com/ezlink/v1` 的 `gpt-image-2` 上游通道超时,服务端不会更换图片模型。
|
||||
- 最近部署验证(2026-05-19):`3756259` 已推送并部署到 `/opt/skg-marketing-studio`;生产 `/health` 显示 `image=gpt-image-2`、`image_fallbacks=['gemini-3-pro-image-preview']`、`subject_image_fallbacks=['gpt-image-2','gemini-3-pro-image-preview']`、短时熔断阈值 2 次 / 600 秒。线上真实探针确认 `gpt-image-2` 读超时后同次调用可自动兜底到 `gemini-3-pro-image-preview` 并返回图片;模拟探针确认连续 2 次主模型失败后第三次直接走 Gemini。
|
||||
- 最近部署验证(2026-05-20):`c245bff` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py` 和 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web 容器 Up、API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。容器内模型偏好探针确认转换层 `image_model_preference` 路由:`auto -> ['gpt-image-2','gemini-3-pro-image-preview']`、`gpt-image-2 -> ['gpt-image-2']`、`gemini-3-pro-image-preview -> ['gemini-3-pro-image-preview']`。
|
||||
- 最近部署验证(2026-05-20):`2366662` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过。容器内后处理探针确认白底小主体保存为 `1152x2048` 时有效主体高度占比从约 0.60 可放大到 `0.906`,主体 6 视图 prompt 已注入同一份 pack bible。
|
||||
- 最近部署验证(2026-05-20):`7acbfd5` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层主体提示词记忆和生图模型偏好改为按 `job.id` 隔离;有参考帧的 `reconstruction_mode=similar` 会先生成 source brief,再把参考帧作为 `/images/edits` 的 `image[]` 参考提交;自主描述空文本切到 `reconstruction_mode=same` 源形象锁定路径。
|
||||
- 最近部署验证(2026-05-20):`e10b1a6` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为项目内生图对话智能体,新增 `Job.subject_agent` 和 `/subject-agent/analyze`、`/subject-agent/message`,GPT / Gemini 改为成套控制分析、对话和生图模型,数量与要求修改进入对话状态后再调用主体套图生成;Pydantic `model_bundle` protected namespace warning 已消除。
|
||||
- 最近部署验证(2026-05-20):`d82175f` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层去掉方向卡片、卡通风格下拉和独立数量按钮,保留单一参考区 + 生图对话;后端 `/subject-agent/message` 从对话中识别 `selected_mode` 和 `quantity` 后再驱动主体套图生成。
|
||||
- 最近部署验证(2026-05-20):`f1c710e` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层中间栏先清空为待重构占位,不再接收拖拽或触发 subject-agent / subject-assets;右侧主体元素输出逻辑保持不变。
|
||||
- 最近部署验证(2026-05-20):`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503,已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`。
|
||||
- 发布状态:已部署并验证(2026-05-15);`https://marketing.skg.com` 已启用应用内登录页,认证后首页 200,`/api/health` 返回 `ok:true`
|
||||
- 主站 / 前端:`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`
|
||||
- 生产部署唯一入口:`./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` 手动同步。
|
||||
- 生产启动:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`
|
||||
- 生产架构:`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
|
||||
- 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 tiny.en` 真实转写,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`。
|
||||
- 持久化目录:服务器 `./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`。
|
||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;默认后端数据库为 `APP_DB_URL=sqlite:////data/jobs/app.db`,只存文档 / job / 媒体资产元数据和文件索引,原视频、音频、抽帧、生图、视频候选仍放在 `/data/jobs/<jobId>/`
|
||||
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
|
||||
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude:`.git`、`.memory`、`.logs`、`.pids`、`data`、`jobs`、`secrets`、`api/jobs`、`api/.env`、`api/.env.local`、`api/.env.production`、`deploy/.env.production`、`web/node_modules`、`web/.next`、`web/out`。不要把本地 `api/.env` 或 `deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
|
||||
|
||||
## 快捷登录
|
||||
- 登录地址:`https://marketing.skg.com/login/`
|
||||
@@ -87,16 +50,10 @@
|
||||
- 能联网和鉴权时必须 `git push origin main`;如果不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因
|
||||
|
||||
## 环境变量
|
||||
- `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 的输入语言提示,默认 `en`;微软官方说明指定 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` 时启用
|
||||
- `ASR_FALLBACK_MODEL`:多模态音频兜底模型,仅在 `ASR_AUDIO_FALLBACK_ENABLED=true` 时用于兜底或音频画像,默认 `gemini-2.5-flash`;如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
|
||||
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 翻译 / 音频分析单次请求超时;当前生产本地转写模式设为 45 秒,微软 ASR 重新启用时可按素材长度提高。
|
||||
- `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 秒,避免第一步长时间停在转录中
|
||||
- `LOCAL_ASR_BIN` / `LOCAL_ASR_MODEL` / `LOCAL_ASR_TIMEOUT_SECONDS`:本机 ASR 兜底,默认使用 `/opt/homebrew/bin/mlx_whisper` + `mlx-community/whisper-tiny`,用于当前 SKG 网关 `/audio/transcriptions` 不可用时生成真实逐句时间轴
|
||||
- `TRANSLATE_MODEL`:字幕翻译模型,默认 `gemini-2.5-flash`
|
||||
- `GPT_TEXT_MODEL`:GPT 文本 / 视觉默认模型,默认 `gpt-4o`;用于兜底修正旧 Gemini 覆盖值
|
||||
@@ -105,21 +62,18 @@
|
||||
- `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`
|
||||
- `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` 会让主体套图只走所选模型
|
||||
- `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`
|
||||
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError,可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
|
||||
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;生产云端固定使用 cookies 文件 `/run/secrets/tiktok_cookies.txt`(宿主机 `./secrets/tiktok_cookies.txt` 挂载进容器),本地开发可临时用浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
|
||||
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`;旧环境若写 `minimax` 会被忽略
|
||||
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;优先使用 cookies 文件,其次读取本机浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
|
||||
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`
|
||||
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
|
||||
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
|
||||
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量
|
||||
- `APP_DB_URL` / `DATABASE_URL`:后端元数据数据库;当前内置实现支持 `sqlite:///`,生产默认 `sqlite:////data/jobs/app.db`。文档归类以 `documents` 为顶层,一条 TK 链接或一次上传默认一个 document,`jobs` 和 `media_assets` 归属到 `document_id`。
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
|
||||
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
|
||||
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
|
||||
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`;网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt`
|
||||
|
||||
## 规则
|
||||
- 不允许编造不存在的部署域名、账号、密码
|
||||
|
||||
@@ -24,14 +24,9 @@ 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,gemini-3-pro-image-preview
|
||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
||||
# 可选:本地网络需要代理访问 ai.skg.com 时配置;launchd 不一定继承 shell 代理变量。
|
||||
AI_HTTP_PROXY=
|
||||
YTDLP_COOKIES_FILE=
|
||||
@@ -42,7 +37,7 @@ VIDEO_MODEL_KLING=kling-omni
|
||||
VIDEO_MODEL_VEO3=veo-3.1-fast
|
||||
|
||||
# 音频文案改写 + Azure OpenAI 配音
|
||||
AUDIO_REWRITE_MODEL=gemini-2.5-pro
|
||||
AUDIO_REWRITE_MODEL=gpt-4o
|
||||
AUDIO_PRODUCT_BRIEF="SKG 智能按摩产品,主打日常肩颈、腰背、眼部、膝盖或足部放松;广告表达要高级、干净、可信,不做医疗疗效承诺。"
|
||||
# 语音通道服务端固定为 Azure OpenAI。
|
||||
VOICE_PROVIDER=azure_openai
|
||||
@@ -84,7 +79,8 @@ VIDEO_DURATION_FIELD=seconds
|
||||
VIDEO_POLL_TIMEOUT_SECONDS=900
|
||||
|
||||
# 工作目录
|
||||
KEYFRAME_COUNT=12
|
||||
APP_DB_URL=sqlite:///./jobs/app.db
|
||||
KEYFRAME_COUNT=6
|
||||
JOBS_DIR=./jobs
|
||||
|
||||
# CORS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SKG TK 二创 API
|
||||
|
||||
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/音频画像、抽帧、GPT 图像生成/修图、Azure OpenAI TTS 预留和视频候选预留管线。
|
||||
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/英文 SKG 产品介绍文案 + Azure OpenAI 英文配音管线。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -18,23 +18,23 @@ uvicorn main:app --host 127.0.0.1 --port 4291
|
||||
## 路由
|
||||
|
||||
- `GET /health` — 健康检查 + 配置状态
|
||||
- `POST /jobs` `{url}` — 创建 job,后台下载源视频;前端“开始分析”会在视频就绪后自动启动音频解析和视觉抽帧
|
||||
- `POST /jobs/{id}/download/retry` — TK 链接下载失败后重试下载;上传视频任务不能重下载
|
||||
- `GET /documents` — 后端数据库里的文档归类列表;一条 TK 链接或一次上传视频默认一个 document
|
||||
- `POST /jobs` `{url}` — 创建 job,后台下载源视频,视频就绪后可手动解析或提取音频
|
||||
- `GET /jobs/{id}` — 当前状态 + 产物;若原始音轨已拆出,会返回 `source_audio_url`
|
||||
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 中文翻译 + 讲话人 / 节奏 / 背景音分析;当前第一步不默认生成 SKG 新口播或 TTS 配音
|
||||
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 英文产品介绍文案;文案长度按原音频时长估算,配置 Azure OpenAI TTS 后从 Azure 音色池生成配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,可与抽帧并行,不自动触发
|
||||
- `GET /jobs/{id}/video.mp4` — 原视频
|
||||
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端音频波形和多模态音频分析使用
|
||||
- `GET /jobs/{id}/audio-script.mp3` — 后续新配音阶段保留的 Azure OpenAI TTS 文件
|
||||
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张参考帧;当前主流程自动抽 12 张动作 / 节奏参考帧,也支持手动按当前播放点补帧
|
||||
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形
|
||||
- `GET /jobs/{id}/audio-script.mp3` — 英文改写文案的 Azure OpenAI TTS 配音
|
||||
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张关键帧(0-9)
|
||||
|
||||
## Mock 模式
|
||||
|
||||
未设 `LLM_API_KEY` 时,转录走本地 mock,便于 UI 联调;未设 `AZURE_OPENAI_API_KEY` 时,后续 TTS 文件不会生成,但不影响当前第一步音频解析。
|
||||
未设 `LLM_API_KEY` 时,转录走本地 mock,便于 UI 联调;未设 `AZURE_OPENAI_API_KEY` 且无法复用 `LLM_API_KEY` 时只生成改写文案,不生成配音文件。
|
||||
|
||||
## 依赖
|
||||
|
||||
- `ffmpeg` 系统二进制(拆轨 / 抽帧)
|
||||
- `yt-dlp` 系统二进制(也可走 Python 包)
|
||||
- OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写 / 视觉 brief);远端 `whisper-1` 失败后先走本机 `mlx_whisper`,再用 `ASR_FALLBACK_MODEL` 走 Gemini 多模态音频识别,后端会拒绝疑似假字幕或覆盖率过低的时间轴
|
||||
- GPT 图片网关(当前所有生图 / 修图 / 产品视角识别 / 主体资产 / 首尾帧都强制使用 `gpt-image-2`,不做其他图片模型 fallback)
|
||||
- Azure OpenAI TTS(后续新配音阶段使用 `AZURE_OPENAI_API_KEY`;默认模型 `gpt-4o-mini-tts`,按 `AZURE_TTS_PATHS` 依次尝试语音路径)
|
||||
- SQLite 元数据数据库(默认 `APP_DB_URL=sqlite:///./jobs/app.db`);只存 document / job / media asset 元数据,原视频、音频、抽帧和生成文件继续放 `jobs/<jobId>/`
|
||||
- OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写);如果 `/audio/transcriptions` 不可用,会用 `ASR_FALLBACK_MODEL` 走 Gemini 多模态音频识别
|
||||
- Azure OpenAI TTS(英文产品介绍文案配音,使用 `AZURE_OPENAI_API_KEY` 或回退复用 `LLM_API_KEY`;默认音色池 `alloy,verse,shimmer`)
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -81,7 +80,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -154,7 +152,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -227,7 +224,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -300,7 +296,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -369,4 +364,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
536
api/database.py
Normal file
536
api/database.py
Normal file
@@ -0,0 +1,536 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def default_database_url(jobs_dir: Path) -> str:
|
||||
return os.getenv("APP_DB_URL") or os.getenv("DATABASE_URL") or f"sqlite:///{jobs_dir / 'app.db'}"
|
||||
|
||||
|
||||
def redact_database_url(url: str) -> str:
|
||||
if "://" not in url or "@" not in url:
|
||||
return url
|
||||
scheme, rest = url.split("://", 1)
|
||||
_, host = rest.rsplit("@", 1)
|
||||
return f"{scheme}://***@{host}"
|
||||
|
||||
|
||||
def infer_source_kind(url: str) -> str:
|
||||
if url.startswith("upload://"):
|
||||
return "upload"
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
return "tiktok_link"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def default_workflow_mode(source_kind: str) -> str:
|
||||
if source_kind == "upload":
|
||||
return "uploaded_reference"
|
||||
return "feed_recreation"
|
||||
|
||||
|
||||
def document_title(url: str, source_kind: str, fallback: str) -> str:
|
||||
if source_kind == "upload":
|
||||
return url.replace("upload://", "", 1).strip() or fallback
|
||||
if url:
|
||||
return url.strip()[:120]
|
||||
return fallback
|
||||
|
||||
|
||||
def storage_prefix(document_id: str, source_kind: str, workflow_mode: str) -> str:
|
||||
source = source_kind or "unknown"
|
||||
mode = workflow_mode or default_workflow_mode(source)
|
||||
return f"{mode}/{source}/{document_id}"
|
||||
|
||||
|
||||
class AppDatabase:
|
||||
def __init__(self, url: str, jobs_dir: Path):
|
||||
self.url = url
|
||||
self.jobs_dir = jobs_dir
|
||||
self.path = self._sqlite_path(url)
|
||||
self.enabled = True
|
||||
self.error = ""
|
||||
|
||||
@staticmethod
|
||||
def _sqlite_path(url: str) -> Path:
|
||||
if url == ":memory:":
|
||||
return Path(":memory:")
|
||||
if not url.startswith("sqlite:///"):
|
||||
raise RuntimeError("当前内置数据库层只支持 sqlite:/// URL;Postgres 迁移会复用同一张表语义。")
|
||||
raw = url[len("sqlite:///"):]
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
if str(self.path) != ":memory:":
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(self.path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def init(self) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
workflow_mode TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
primary_job_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'created',
|
||||
storage_prefix TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
workflow_mode TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
storage_path TEXT NOT NULL,
|
||||
state_path TEXT NOT NULL,
|
||||
video_url TEXT NOT NULL DEFAULT '',
|
||||
duration REAL NOT NULL DEFAULT 0,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
frame_count INTEGER NOT NULL DEFAULT 0,
|
||||
video_count INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
path TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
frame_index INTEGER,
|
||||
timestamp REAL,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
duration REAL NOT NULL DEFAULT 0,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_source_kind ON documents(source_kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_workflow_mode ON documents(workflow_mode);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_document_id ON jobs(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_document_id ON media_assets(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_job_id ON media_assets(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_role ON media_assets(role);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES('schema_version', ?)",
|
||||
(str(SCHEMA_VERSION),),
|
||||
)
|
||||
|
||||
def normalize_job_document(self, job: dict[str, Any]) -> dict[str, Any]:
|
||||
job_id = str(job.get("id") or "")
|
||||
source_url = str(job.get("url") or "")
|
||||
source_kind = str(job.get("source_kind") or "") or infer_source_kind(source_url)
|
||||
workflow_mode = str(job.get("workflow_mode") or "") or default_workflow_mode(source_kind)
|
||||
document_id = str(job.get("document_id") or "") or job_id
|
||||
prefix = str(job.get("storage_prefix") or "") or storage_prefix(document_id, source_kind, workflow_mode)
|
||||
return {
|
||||
"document_id": document_id,
|
||||
"source_kind": source_kind,
|
||||
"workflow_mode": workflow_mode,
|
||||
"storage_prefix": prefix,
|
||||
"title": document_title(source_url, source_kind, document_id),
|
||||
}
|
||||
|
||||
def sync_job(self, job: dict[str, Any], job_path: Path) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
now = time.time()
|
||||
job_id = str(job.get("id") or "")
|
||||
if not job_id:
|
||||
return
|
||||
doc = self.normalize_job_document(job)
|
||||
state_path = job_path / "state.json"
|
||||
frames = list(job.get("frames") or [])
|
||||
generated_videos = list(job.get("generated_videos") or [])
|
||||
metadata = {
|
||||
"audio_segment_count": len(job.get("transcript") or []),
|
||||
"product_ref_count": len(job.get("product_refs") or []),
|
||||
"storyboard_image_count": len(job.get("storyboard_images") or []),
|
||||
}
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT created_at FROM documents WHERE id = ?",
|
||||
(doc["document_id"],),
|
||||
).fetchone()
|
||||
created_at = float(existing["created_at"]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO documents(
|
||||
id, title, source_kind, workflow_mode, source_url, primary_job_id,
|
||||
status, storage_prefix, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
source_kind = excluded.source_kind,
|
||||
workflow_mode = excluded.workflow_mode,
|
||||
source_url = excluded.source_url,
|
||||
primary_job_id = excluded.primary_job_id,
|
||||
status = excluded.status,
|
||||
storage_prefix = excluded.storage_prefix,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
doc["document_id"],
|
||||
doc["title"],
|
||||
doc["source_kind"],
|
||||
doc["workflow_mode"],
|
||||
str(job.get("url") or ""),
|
||||
job_id,
|
||||
str(job.get("status") or "created"),
|
||||
doc["storage_prefix"],
|
||||
json.dumps(metadata, ensure_ascii=False),
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
existing_job = conn.execute("SELECT created_at FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
job_created_at = float(existing_job["created_at"]) if existing_job else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs(
|
||||
id, document_id, source_kind, workflow_mode, source_url, status,
|
||||
progress, message, storage_path, state_path, video_url, duration,
|
||||
width, height, frame_count, video_count, error, metadata_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
document_id = excluded.document_id,
|
||||
source_kind = excluded.source_kind,
|
||||
workflow_mode = excluded.workflow_mode,
|
||||
source_url = excluded.source_url,
|
||||
status = excluded.status,
|
||||
progress = excluded.progress,
|
||||
message = excluded.message,
|
||||
storage_path = excluded.storage_path,
|
||||
state_path = excluded.state_path,
|
||||
video_url = excluded.video_url,
|
||||
duration = excluded.duration,
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
frame_count = excluded.frame_count,
|
||||
video_count = excluded.video_count,
|
||||
error = excluded.error,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
doc["document_id"],
|
||||
doc["source_kind"],
|
||||
doc["workflow_mode"],
|
||||
str(job.get("url") or ""),
|
||||
str(job.get("status") or "created"),
|
||||
int(job.get("progress") or 0),
|
||||
str(job.get("message") or ""),
|
||||
str(job_path),
|
||||
str(state_path),
|
||||
str(job.get("video_url") or ""),
|
||||
float(job.get("duration") or 0),
|
||||
int(job.get("width") or 0),
|
||||
int(job.get("height") or 0),
|
||||
len(frames),
|
||||
len(generated_videos),
|
||||
str(job.get("error") or ""),
|
||||
json.dumps(metadata, ensure_ascii=False),
|
||||
job_created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute("DELETE FROM media_assets WHERE job_id = ?", (job_id,))
|
||||
for asset in self._job_assets(job, job_path, doc["document_id"]):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO media_assets(
|
||||
id, document_id, job_id, kind, role, path, url, frame_index,
|
||||
timestamp, width, height, duration, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
asset["id"],
|
||||
asset["document_id"],
|
||||
asset["job_id"],
|
||||
asset["kind"],
|
||||
asset["role"],
|
||||
asset.get("path", ""),
|
||||
asset.get("url", ""),
|
||||
asset.get("frame_index"),
|
||||
asset.get("timestamp"),
|
||||
int(asset.get("width") or 0),
|
||||
int(asset.get("height") or 0),
|
||||
float(asset.get("duration") or 0),
|
||||
json.dumps(asset.get("metadata") or {}, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
def _job_assets(self, job: dict[str, Any], job_path: Path, document_id: str) -> list[dict[str, Any]]:
|
||||
job_id = str(job.get("id") or "")
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
def add(
|
||||
asset_id: str,
|
||||
kind: str,
|
||||
role: str,
|
||||
path: Path | str = "",
|
||||
url: str = "",
|
||||
frame_index: int | None = None,
|
||||
timestamp: float | None = None,
|
||||
width: int = 0,
|
||||
height: int = 0,
|
||||
duration: float = 0.0,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
items.append({
|
||||
"id": asset_id,
|
||||
"document_id": document_id,
|
||||
"job_id": job_id,
|
||||
"kind": kind,
|
||||
"role": role,
|
||||
"path": str(path) if path else "",
|
||||
"url": url,
|
||||
"frame_index": frame_index,
|
||||
"timestamp": timestamp,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"duration": duration,
|
||||
"metadata": metadata or {},
|
||||
})
|
||||
|
||||
if (job_path / "source.mp4").exists() or job.get("video_url"):
|
||||
add(
|
||||
f"{job_id}:source_video",
|
||||
"video",
|
||||
"source_video",
|
||||
job_path / "source.mp4",
|
||||
str(job.get("video_url") or f"/jobs/{job_id}/video.mp4"),
|
||||
duration=float(job.get("duration") or 0),
|
||||
width=int(job.get("width") or 0),
|
||||
height=int(job.get("height") or 0),
|
||||
)
|
||||
if (job_path / "audio.wav").exists() or job.get("source_audio_url"):
|
||||
add(
|
||||
f"{job_id}:source_audio",
|
||||
"audio",
|
||||
"source_audio",
|
||||
job_path / "audio.wav",
|
||||
str(job.get("source_audio_url") or f"/jobs/{job_id}/audio.wav"),
|
||||
duration=float(job.get("duration") or 0),
|
||||
)
|
||||
|
||||
for frame in job.get("frames") or []:
|
||||
idx = int(frame.get("index") or 0)
|
||||
add(
|
||||
f"{job_id}:frame:{idx}",
|
||||
"image",
|
||||
"keyframe",
|
||||
job_path / "frames" / f"{idx:03d}.jpg",
|
||||
str(frame.get("url") or f"/jobs/{job_id}/frames/{idx}.jpg"),
|
||||
frame_index=idx,
|
||||
timestamp=float(frame.get("timestamp") or 0),
|
||||
metadata={"quality_report": frame.get("quality_report")},
|
||||
)
|
||||
if frame.get("cleaned_url"):
|
||||
add(
|
||||
f"{job_id}:frame:{idx}:cleaned",
|
||||
"image",
|
||||
"cleaned_keyframe",
|
||||
job_path / "cleaned" / f"{idx:03d}.jpg",
|
||||
str(frame.get("cleaned_url")),
|
||||
frame_index=idx,
|
||||
timestamp=float(frame.get("timestamp") or 0),
|
||||
)
|
||||
for generated in frame.get("generated_images") or []:
|
||||
gen_id = str(generated.get("id") or "")
|
||||
if gen_id:
|
||||
add(
|
||||
f"{job_id}:generated_image:{idx}:{gen_id}",
|
||||
"image",
|
||||
"generated_image",
|
||||
job_path / "gen" / f"{idx:03d}_{gen_id}.jpg",
|
||||
str(generated.get("url") or ""),
|
||||
frame_index=idx,
|
||||
metadata={"model": generated.get("model"), "mode": generated.get("mode")},
|
||||
)
|
||||
for scene_asset in frame.get("scene_assets") or []:
|
||||
asset_id = str(scene_asset.get("id") or "")
|
||||
if asset_id:
|
||||
add(
|
||||
f"{job_id}:scene_asset:{asset_id}",
|
||||
"image",
|
||||
str(scene_asset.get("asset_role") or "scene_asset"),
|
||||
job_path / "assets" / f"{asset_id}.jpg",
|
||||
str(scene_asset.get("url") or ""),
|
||||
frame_index=idx,
|
||||
width=int(scene_asset.get("width") or 0),
|
||||
height=int(scene_asset.get("height") or 0),
|
||||
metadata={"label": scene_asset.get("label"), "scene_mode": scene_asset.get("scene_mode")},
|
||||
)
|
||||
for element in frame.get("elements") or []:
|
||||
element_id = str(element.get("id") or "")
|
||||
cutout_ids = list(element.get("cutouts") or [])
|
||||
legacy_cutout = element.get("cutout_id")
|
||||
if legacy_cutout and legacy_cutout not in cutout_ids:
|
||||
cutout_ids.append(legacy_cutout)
|
||||
for cutout_id in cutout_ids:
|
||||
add(
|
||||
f"{job_id}:cutout:{idx}:{element_id}:{cutout_id}",
|
||||
"image",
|
||||
"element_cutout",
|
||||
job_path / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg",
|
||||
f"/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}.jpg",
|
||||
frame_index=idx,
|
||||
metadata={"element_id": element_id, "name_zh": element.get("name_zh")},
|
||||
)
|
||||
for subject_asset in element.get("subject_assets") or []:
|
||||
asset_id = str(subject_asset.get("id") or "")
|
||||
if asset_id:
|
||||
add(
|
||||
f"{job_id}:subject_asset:{asset_id}",
|
||||
"image",
|
||||
"subject_asset",
|
||||
job_path / "assets" / f"{asset_id}.jpg",
|
||||
str(subject_asset.get("url") or ""),
|
||||
frame_index=idx,
|
||||
width=int(subject_asset.get("width") or 0),
|
||||
height=int(subject_asset.get("height") or 0),
|
||||
metadata={"view": subject_asset.get("view"), "label": subject_asset.get("label")},
|
||||
)
|
||||
|
||||
for ref in job.get("product_refs") or []:
|
||||
asset_id = str(ref.get("id") or ref.get("asset_id") or ref.get("url") or "")
|
||||
if asset_id:
|
||||
add(
|
||||
f"{job_id}:product_ref:{asset_id}",
|
||||
"image",
|
||||
"product_ref",
|
||||
self._path_from_job_url(job_path, job_id, str(ref.get("url") or "")),
|
||||
str(ref.get("url") or ""),
|
||||
metadata=ref,
|
||||
)
|
||||
|
||||
for video in job.get("generated_videos") or []:
|
||||
video_id = str(video.get("id") or "")
|
||||
if video_id:
|
||||
add(
|
||||
f"{job_id}:generated_video:{video_id}",
|
||||
"video",
|
||||
"generated_video",
|
||||
job_path / "videos" / f"{video_id}.mp4",
|
||||
str(video.get("url") or ""),
|
||||
frame_index=video.get("frame_idx"),
|
||||
duration=float(video.get("duration") or 0),
|
||||
metadata={"status": video.get("status"), "model": video.get("model"), "error": video.get("error")},
|
||||
)
|
||||
return items
|
||||
|
||||
def _path_from_job_url(self, job_path: Path, job_id: str, url: str) -> str:
|
||||
prefix = f"/jobs/{job_id}/"
|
||||
if not url.startswith(prefix):
|
||||
return ""
|
||||
tail = url[len(prefix):]
|
||||
if tail == "video.mp4":
|
||||
return str(job_path / "source.mp4")
|
||||
return str(job_path / tail)
|
||||
|
||||
def delete_job(self, job_id: str) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT document_id FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
||||
if row:
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM jobs WHERE document_id = ?",
|
||||
(row["document_id"],),
|
||||
).fetchone()
|
||||
if int(remaining["c"] or 0) == 0:
|
||||
conn.execute("DELETE FROM documents WHERE id = ?", (row["document_id"],))
|
||||
|
||||
def list_documents(self, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
sql = """
|
||||
SELECT
|
||||
d.*,
|
||||
COUNT(DISTINCT j.id) AS job_count,
|
||||
COUNT(DISTINCT a.id) AS asset_count
|
||||
FROM documents d
|
||||
LEFT JOIN jobs j ON j.document_id = d.id
|
||||
LEFT JOIN media_assets a ON a.document_id = d.id
|
||||
GROUP BY d.id
|
||||
ORDER BY d.updated_at DESC
|
||||
"""
|
||||
params: tuple[Any, ...] = ()
|
||||
if limit is not None and limit > 0:
|
||||
sql += " LIMIT ?"
|
||||
params = (limit,)
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
if not self.enabled:
|
||||
return {"enabled": False, "url": redact_database_url(self.url), "error": self.error}
|
||||
try:
|
||||
with self.connect() as conn:
|
||||
docs = conn.execute("SELECT COUNT(*) AS c FROM documents").fetchone()["c"]
|
||||
jobs = conn.execute("SELECT COUNT(*) AS c FROM jobs").fetchone()["c"]
|
||||
assets = conn.execute("SELECT COUNT(*) AS c FROM media_assets").fetchone()["c"]
|
||||
return {
|
||||
"enabled": True,
|
||||
"url": redact_database_url(self.url),
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"documents": int(docs or 0),
|
||||
"jobs": int(jobs or 0),
|
||||
"assets": int(assets or 0),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"enabled": False, "url": redact_database_url(self.url), "error": str(e)}
|
||||
|
||||
|
||||
def create_database(url: str, jobs_dir: Path) -> AppDatabase:
|
||||
db = AppDatabase(url, jobs_dir)
|
||||
db.init()
|
||||
return db
|
||||
3111
api/main.py
3111
api/main.py
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,6 @@ 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
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
|
||||
# Runtime
|
||||
JOBS_DIR=/data/jobs
|
||||
ASSET_LIBRARY_DIR=/data/asset_library
|
||||
PROMPT_LIBRARY_DIR=/data/prompt_library
|
||||
KEYFRAME_COUNT=12
|
||||
APP_DB_URL=sqlite:////data/jobs/app.db
|
||||
KEYFRAME_COUNT=6
|
||||
CORS_ORIGINS=https://marketing.skg.com
|
||||
API_PORT=4291
|
||||
|
||||
@@ -21,19 +20,8 @@ LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
LLM_API_KEY=
|
||||
|
||||
# Model routing
|
||||
# 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_MODEL=whisper-1
|
||||
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
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
@@ -42,24 +30,18 @@ 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,gemini-3-pro-image-preview
|
||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
||||
# 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_REWRITE_MODEL=gpt-4o
|
||||
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
|
||||
|
||||
@@ -10,15 +10,9 @@ services:
|
||||
- ./deploy/.env.production
|
||||
environment:
|
||||
JOBS_DIR: /data/jobs
|
||||
ASSET_LIBRARY_DIR: /data/asset_library
|
||||
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||
CORS_ORIGINS: https://marketing.skg.com
|
||||
volumes:
|
||||
- ./data/jobs:/data/jobs
|
||||
- ./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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,71 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/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\\\")\"
|
||||
'"
|
||||
@@ -464,397 +464,6 @@ nextjs-portal {
|
||||
min-width: 0;
|
||||
transform: translateY(44px);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
信息流工作台 · 登录页同源质感
|
||||
============================================================ */
|
||||
.skg-board-theme {
|
||||
--skg-gold-1: #d6b36a;
|
||||
--skg-gold-2: #c89b3c;
|
||||
--skg-cream: #f5efe3;
|
||||
--skg-bg-1: #0a0a0a;
|
||||
--skg-bg-2: #111111;
|
||||
--skg-bg-3: rgba(255, 255, 255, 0.035);
|
||||
--skg-border: rgba(255, 255, 255, 0.1);
|
||||
--skg-text-1: #ffffff;
|
||||
--skg-text-2: rgba(255, 255, 255, 0.62);
|
||||
--skg-text-3: rgba(255, 255, 255, 0.34);
|
||||
--skg-success: #34d399;
|
||||
--skg-warn: #fcd34d;
|
||||
--skg-danger: #fb7185;
|
||||
--skg-info: #67e8f9;
|
||||
--skg-radius-sm: 6px;
|
||||
--skg-radius-md: 8px;
|
||||
--skg-radius-lg: 12px;
|
||||
--skg-shadow-button: 0 6px 24px -8px rgba(0, 0, 0, 0.45);
|
||||
color: var(--skg-text-1);
|
||||
background:
|
||||
radial-gradient(circle at 52% 4%, rgba(214, 179, 106, 0.1), transparent 30%),
|
||||
radial-gradient(circle at 12% 96%, rgba(214, 179, 106, 0.065), transparent 34%),
|
||||
linear-gradient(120deg, #0a0a0a 0%, #10100f 46%, #050505 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.026) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
opacity: 0.44;
|
||||
}
|
||||
|
||||
.skg-board-theme::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.22), transparent 42%, rgba(0, 0, 0, 0.4)),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.28), transparent 38%, rgba(0, 0, 0, 0.24));
|
||||
}
|
||||
|
||||
.skg-board-ambient {
|
||||
background:
|
||||
radial-gradient(circle at 78% 0%, rgba(232, 201, 122, 0.08), transparent 30%),
|
||||
radial-gradient(circle at 8% 100%, rgba(214, 179, 106, 0.06), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-topbar,
|
||||
.skg-board-panel {
|
||||
border-color: var(--skg-border) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.022)),
|
||||
rgba(17, 17, 17, 0.74) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
0 18px 54px rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.skg-board-topbar {
|
||||
background:
|
||||
linear-gradient(100deg, rgba(214, 179, 106, 0.075), rgba(255, 255, 255, 0.03) 54%, rgba(214, 179, 106, 0.035)),
|
||||
rgba(12, 12, 12, 0.76) !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(214, 179, 106, 0.14);
|
||||
}
|
||||
|
||||
.skg-board-theme input[type="checkbox"] {
|
||||
accent-color: #d6b36a;
|
||||
}
|
||||
|
||||
.skg-board-theme ::selection {
|
||||
background: rgba(214, 179, 106, 0.28);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skg-board-theme--light {
|
||||
--skg-bg-1: #faf8f4;
|
||||
--skg-bg-2: #ffffff;
|
||||
--skg-bg-3: rgba(0, 0, 0, 0.03);
|
||||
--skg-border: rgba(0, 0, 0, 0.08);
|
||||
--skg-text-1: #0a0a0a;
|
||||
--skg-text-2: rgba(0, 0, 0, 0.62);
|
||||
--skg-text-3: rgba(0, 0, 0, 0.34);
|
||||
--skg-success: #059669;
|
||||
--skg-warn: #b7791f;
|
||||
--skg-danger: #e11d48;
|
||||
--skg-info: #0891b2;
|
||||
color: var(--skg-text-1);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.18), transparent 31%),
|
||||
radial-gradient(circle at 4% 100%, rgba(214, 179, 106, 0.12), transparent 28%),
|
||||
linear-gradient(126deg, #faf8f4 0%, #f4efe5 48%, #ffffff 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:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.48)),
|
||||
rgba(249, 246, 236, 0.7) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.78),
|
||||
0 18px 48px rgba(65, 55, 30, 0.1);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-topbar {
|
||||
background:
|
||||
linear-gradient(100deg, rgba(214, 179, 106, 0.14), rgba(143, 176, 113, 0.08) 42%, rgba(255, 255, 255, 0.58)),
|
||||
rgba(252, 249, 241, 0.82) !important;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border: 1px solid rgba(214, 179, 106, 0.18);
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: var(--skg-cream);
|
||||
color: #0a0a0a;
|
||||
box-shadow: var(--skg-shadow-button);
|
||||
}
|
||||
|
||||
.skg-stat-card__label {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.skg-stat-card__value {
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
.skg-primary-action {
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: #f5efe3;
|
||||
color: #0a0a0a;
|
||||
box-shadow: var(--skg-shadow-button);
|
||||
}
|
||||
|
||||
.skg-primary-action:hover {
|
||||
background: #fff7df;
|
||||
}
|
||||
|
||||
.skg-secondary-action {
|
||||
border: 1px solid rgba(214, 179, 106, 0.3);
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: rgba(214, 179, 106, 0.08);
|
||||
color: var(--skg-gold-1);
|
||||
}
|
||||
|
||||
.skg-secondary-action:hover {
|
||||
border-color: rgba(214, 179, 106, 0.54);
|
||||
background: rgba(214, 179, 106, 0.12);
|
||||
color: #f5d98e;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -12px rgba(133, 96, 21, 0.38);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card__label {
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card__value {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-primary-action {
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -12px rgba(133, 96, 21, 0.42);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-primary-action:hover {
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-empty-state {
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.28), transparent 38%),
|
||||
rgba(255, 255, 255, 0.66);
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
isolation: isolate;
|
||||
color: #282828;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import {
|
||||
ReactFlow, Background, BackgroundVariant, Controls,
|
||||
useNodesState, useEdgesState,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
type CanvasPanelDock,
|
||||
type NodeData,
|
||||
} from "@/components/nodes"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { AdRecreationBoard } from "@/components/ad-recreation-board"
|
||||
import {
|
||||
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
||||
@@ -39,6 +41,7 @@ const VIDEO_FRAME_PANEL_ID = "video-frame-panel"
|
||||
const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID])
|
||||
const DIRECT_VIDEO_GENERATION_PAUSED = true
|
||||
const FRAME_TARGET_LABELS: Record<FrameExtractTarget, string> = {
|
||||
random_subject: "人物随机",
|
||||
transparent_human: "透明骨架人",
|
||||
balanced: "综合关键帧",
|
||||
subject: "清晰主体",
|
||||
@@ -60,11 +63,6 @@ const DEFAULT_PRODUCT_LIBRARY_IDS = [
|
||||
]
|
||||
const VIDEO_READY_STATUSES: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
|
||||
|
||||
function isAudioProcessing(job?: Job | null) {
|
||||
if (!job) return false
|
||||
return job.audio_script?.status === "rewriting" || (job.status === "transcribing" && job.audio_script?.status !== "failed")
|
||||
}
|
||||
|
||||
const PRODUCT_FUSION_WEARING_PROMPT = [
|
||||
"Product placement must be physically correct:",
|
||||
"The SKG device is a rigid opaque white U-shaped neck massager, not a soft scarf, necklace, cable, collar, sticker, implant, or transparent body part.",
|
||||
@@ -148,6 +146,7 @@ const EDGES_RAW: Array<[string, string]> = [
|
||||
]
|
||||
|
||||
export default function Home() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||
const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
|
||||
@@ -245,8 +244,8 @@ export default function Home() {
|
||||
const handleAnalyzeJob = useCallback(async (jobId: string, options?: { mode?: FrameExtractMode }) => {
|
||||
const targetJob = jobs.find((item) => item.id === jobId)
|
||||
if (!targetJob) return
|
||||
const frameTarget = frameTargets[jobId] ?? "transparent_human"
|
||||
const frameCount = frameCounts[jobId] ?? 12
|
||||
const frameTarget = frameTargets[jobId] ?? "random_subject"
|
||||
const frameCount = frameCounts[jobId] ?? 6
|
||||
const frameQuality = frameQualities[jobId] ?? "auto"
|
||||
const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
|
||||
setActiveJobId(jobId)
|
||||
@@ -291,10 +290,8 @@ export default function Home() {
|
||||
updateJobInList(updated)
|
||||
setActiveJobId((prev) => prev ?? updated.id)
|
||||
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`)
|
||||
return updated
|
||||
} catch (e) {
|
||||
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
return undefined
|
||||
}
|
||||
}, [updateJobInList])
|
||||
|
||||
@@ -455,7 +452,7 @@ export default function Home() {
|
||||
if (!options?.silent) toast.info("视频导入完成后,可在音频卡片点击提取音频")
|
||||
return
|
||||
}
|
||||
if (isAudioProcessing(target)) {
|
||||
if (target.status === "transcribing" || target.audio_script?.status === "rewriting") {
|
||||
if (!options?.silent) toast.info("音频正在处理中")
|
||||
return
|
||||
}
|
||||
@@ -473,9 +470,8 @@ export default function Home() {
|
||||
if (!videoReady) return
|
||||
|
||||
const audioKey = `${target.id}:audio`
|
||||
const audioFailed = target.audio_script?.status === "failed"
|
||||
const hasAudioResult = !audioFailed && (!!target.audio_script?.source_text || target.transcript.length > 0)
|
||||
const audioRunning = isAudioProcessing(target)
|
||||
const hasAudioResult = !!target.audio_script?.source_text || target.transcript.length > 0
|
||||
const audioRunning = target.status === "transcribing" || target.audio_script?.status === "rewriting"
|
||||
if (!hasAudioResult && !audioRunning && !autoTriggeredRef.current.has(audioKey)) {
|
||||
autoTriggeredRef.current.add(audioKey)
|
||||
try {
|
||||
@@ -493,8 +489,8 @@ export default function Home() {
|
||||
const visualRunning = target.status === "splitting"
|
||||
if (!hasVisualResult && !visualRunning && !autoTriggeredRef.current.has(visualKey)) {
|
||||
autoTriggeredRef.current.add(visualKey)
|
||||
const frameTarget = frameTargets[target.id] ?? "motion"
|
||||
const frameCount = frameCounts[target.id] ?? 12
|
||||
const frameTarget = frameTargets[target.id] ?? "random_subject"
|
||||
const frameCount = frameCounts[target.id] ?? 6
|
||||
const frameQuality = frameQualities[target.id] ?? "accurate"
|
||||
try {
|
||||
const updated = await analyzeJob(target.id, frameCount, frameTarget, "replace", frameQuality)
|
||||
@@ -894,12 +890,7 @@ export default function Home() {
|
||||
.filter((item) => {
|
||||
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||
const runningAudio = item.audio_script?.status === "rewriting"
|
||||
const runningSubject = item.frames.some((frame) =>
|
||||
frame.elements?.some((element) =>
|
||||
element.subject_assets?.some((asset) => asset.status === "queued" || asset.status === "in_progress"),
|
||||
),
|
||||
)
|
||||
return runningVideo || runningAudio || runningSubject || !TERMINAL.includes(item.status)
|
||||
return runningVideo || runningAudio || !TERMINAL.includes(item.status)
|
||||
})
|
||||
.map((item) => item.id)
|
||||
|
||||
@@ -920,14 +911,7 @@ export default function Home() {
|
||||
}, [
|
||||
job?.id,
|
||||
job?.status,
|
||||
jobs.map((item) => {
|
||||
const subjectState = item.frames.flatMap((frame) =>
|
||||
frame.elements?.flatMap((element) =>
|
||||
element.subject_assets?.map((asset) => `${asset.id}:${asset.status ?? "completed"}:${asset.progress ?? 100}:${asset.url ?? ""}`) ?? [],
|
||||
) ?? [],
|
||||
).join(",")
|
||||
return `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}:${subjectState}`
|
||||
}).join("|"),
|
||||
jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
|
||||
])
|
||||
|
||||
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
|
||||
@@ -1235,6 +1219,9 @@ export default function Home() {
|
||||
<div className="canvas-bg" />
|
||||
<main className="relative flex h-screen w-screen overflow-hidden">
|
||||
<AdRecreationBoard data={nodeData} onGenerateVideo={handleQuickGenerateVideo} />
|
||||
<div className="absolute bottom-4 right-4 z-30 pointer-events-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Toaster theme="system" position="top-center" />
|
||||
</main>
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)]">候选帧 → pHash 去重 + 清晰度排序 + 时序分桶 → 按当前设置产出参考帧</div>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)]">候选 30 张 → pHash 去重 + 清晰度排序 → 时序分桶 → 5 张代表分镜</div>
|
||||
</KanbanCard>
|
||||
) : (
|
||||
job!.frames.map((f) => {
|
||||
@@ -646,7 +646,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
<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 || "当前第一步不默认生成配音文件;后续新配音阶段走 Azure OpenAI TTS"}
|
||||
{job?.audio_script?.error || "配置 Azure OpenAI TTS 后自动生成配音文件"}
|
||||
</div>
|
||||
)}
|
||||
<div className="kanban-meta">{job?.audio_script?.voice_id || "Azure voice"}</div>
|
||||
@@ -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)]">按后端 VIDEO_CREATE_PATHS 提交,模型 ID 走环境变量映射</div>
|
||||
<div className="text-[11px] text-[var(--text-soft)]">通过 /v1/videos 网关提交,模型 ID 走环境变量映射</div>
|
||||
</KanbanCard>
|
||||
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">字节跳动 · 需独立 API key</div>
|
||||
|
||||
@@ -14,8 +14,6 @@ type MediaAssetAction = {
|
||||
tone?: "neutral" | "cyan" | "rose"
|
||||
}
|
||||
|
||||
type MediaAssetPreviewPlacement = "auto" | "left" | "right"
|
||||
|
||||
type MediaAssetTileProps = {
|
||||
kind?: "image" | "video"
|
||||
src?: string
|
||||
@@ -31,8 +29,6 @@ type MediaAssetTileProps = {
|
||||
objectFit?: "contain" | "cover"
|
||||
previewObjectFit?: "contain" | "cover"
|
||||
previewClassName?: string
|
||||
previewPlacement?: MediaAssetPreviewPlacement
|
||||
previewMaxWidth?: number
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
busy?: boolean
|
||||
@@ -59,21 +55,15 @@ function mediaObjectClass(fit: "contain" | "cover") {
|
||||
return fit === "cover" ? "object-cover" : "object-contain"
|
||||
}
|
||||
|
||||
function previewPosition(event: ReactMouseEvent<HTMLElement>, placement: MediaAssetPreviewPlacement, maxWidth: number) {
|
||||
function previewPosition(event: ReactMouseEvent<HTMLElement>) {
|
||||
const margin = 16
|
||||
const previewWidth = Math.min(maxWidth, window.innerWidth - margin * 2)
|
||||
const previewWidth = Math.min(520, window.innerWidth - margin * 2)
|
||||
const previewHeight = Math.min(760, window.innerHeight - margin * 2)
|
||||
let left = placement === "left" ? event.clientX - previewWidth - 18 : event.clientX + 18
|
||||
let left = event.clientX + 18
|
||||
let top = event.clientY + 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 (left + previewWidth > window.innerWidth - margin) left = event.clientX - previewWidth - 18
|
||||
if (top + previewHeight > window.innerHeight - margin) top = window.innerHeight - previewHeight - margin
|
||||
return {
|
||||
left: Math.max(margin, Math.min(left, window.innerWidth - previewWidth - margin)),
|
||||
top: Math.max(margin, top),
|
||||
width: previewWidth,
|
||||
}
|
||||
return { left: Math.max(margin, left), top: Math.max(margin, top) }
|
||||
}
|
||||
|
||||
export function MediaAssetTile({
|
||||
@@ -91,8 +81,6 @@ export function MediaAssetTile({
|
||||
objectFit = "contain",
|
||||
previewObjectFit,
|
||||
previewClassName = "",
|
||||
previewPlacement = "auto",
|
||||
previewMaxWidth = 520,
|
||||
selected = false,
|
||||
disabled = false,
|
||||
busy = false,
|
||||
@@ -108,7 +96,7 @@ export function MediaAssetTile({
|
||||
actions = [],
|
||||
disablePreview = false,
|
||||
}: MediaAssetTileProps) {
|
||||
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
const mediaSrc = src || poster || ""
|
||||
const canPreview = !!mediaSrc && !disablePreview
|
||||
const fit = mediaObjectClass(objectFit)
|
||||
@@ -116,7 +104,7 @@ export function MediaAssetTile({
|
||||
|
||||
const updatePreview = (event: ReactMouseEvent<HTMLElement>) => {
|
||||
if (!canPreview) return
|
||||
setPosition(previewPosition(event, previewPlacement, previewMaxWidth))
|
||||
setPosition(previewPosition(event))
|
||||
}
|
||||
|
||||
const media = kind === "video" && src ? (
|
||||
@@ -148,7 +136,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, width: position.width }}
|
||||
style={{ left: position.left, top: position.top }}
|
||||
>
|
||||
<div className="flex max-h-[min(76vh,720px)] items-center justify-center overflow-hidden rounded-lg bg-black">
|
||||
{kind === "video" && src ? (
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface NodeData {
|
||||
onFramePanelDockChange?: (dock: CanvasPanelDock) => void
|
||||
onCloseExpandedFrame: () => void
|
||||
onAddManualFrame: (t: number) => void
|
||||
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<Job | void> | Job | void
|
||||
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<void> | void
|
||||
onOpenVideoPanel?: (jobId: string) => void
|
||||
onCloseVideoPanel?: () => void
|
||||
onVideoPanelScaleChange?: (scale: number) => void
|
||||
@@ -133,6 +133,7 @@ function clamp(value: number, min: number, max: number) {
|
||||
const THUMBNAIL_HEIGHT = 192
|
||||
const FLOATING_PANEL_EDGE_INSET = 8
|
||||
const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hint: string }> = [
|
||||
{ value: "random_subject", label: "人物随机", hint: "从清晰人物候选里随机抽取" },
|
||||
{ value: "transparent_human", label: "透明骨架人", hint: "本地算力筛清晰主体,不逐帧调用 Vision" },
|
||||
{ value: "balanced", label: "综合关键帧", hint: "清晰、去重、变化、时间覆盖" },
|
||||
{ value: "subject", label: "清晰主体", hint: "人物 / 产品主体更清楚" },
|
||||
@@ -140,7 +141,7 @@ const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hi
|
||||
{ value: "expression", label: "表情瞬间", hint: "人物 / 动物表情倾向" },
|
||||
{ value: "motion", label: "动作峰值", hint: "动作变化更明显" },
|
||||
]
|
||||
const FRAME_COUNT_OPTIONS = [12, 8, 5, 3]
|
||||
const FRAME_COUNT_OPTIONS = [6, 12, 8, 5, 3]
|
||||
const FRAME_QUALITY_OPTIONS: Array<{ value: FrameExtractQuality; label: string; hint: string }> = [
|
||||
{ value: "auto", label: "自动", hint: "展示友好:按电脑性能选择,最高只到精细" },
|
||||
{ value: "fast", label: "快速", hint: "2fps / 360px,长视频省电" },
|
||||
@@ -575,8 +576,8 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
const aspectStr = ready ? `${j.width}/${j.height}` : "9/16"
|
||||
const thumbNaturalWidth = ready && j.height ? Math.max(96, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 96
|
||||
const toolWidth = Math.max(148, thumbNaturalWidth)
|
||||
const target = d.frameTargets[j.id] ?? "transparent_human"
|
||||
const count = d.frameCounts[j.id] ?? 12
|
||||
const target = d.frameTargets[j.id] ?? "random_subject"
|
||||
const count = d.frameCounts[j.id] ?? 6
|
||||
const quality = d.frameQualities[j.id] ?? "auto"
|
||||
const jHasFrames = j.frames.length > 0
|
||||
const jRunning = ["splitting", "transcribing"].includes(j.status)
|
||||
@@ -815,8 +816,8 @@ export function VideoFramePanelNode({ data }: any) {
|
||||
const duration = panelJob.duration ?? 0
|
||||
const frames = [...panelJob.frames].sort((a, b) => a.timestamp - b.timestamp)
|
||||
const aspect = panelJob.width && panelJob.height ? `${panelJob.width}/${panelJob.height}` : "9/16"
|
||||
const panelTarget = d.frameTargets[panelJob.id] ?? "transparent_human"
|
||||
const panelCount = d.frameCounts[panelJob.id] ?? 12
|
||||
const panelTarget = d.frameTargets[panelJob.id] ?? "random_subject"
|
||||
const panelCount = d.frameCounts[panelJob.id] ?? 6
|
||||
const panelQuality = d.frameQualities[panelJob.id] ?? "auto"
|
||||
const panelRunning = ["splitting", "transcribing"].includes(panelJob.status)
|
||||
const dockText: Record<CanvasPanelDock, string> = {
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
559
web/lib/api.ts
559
web/lib/api.ts
@@ -58,13 +58,11 @@ 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" | "library_subject" | "library_product" | "library_scene"
|
||||
kind: "keyframe" | "cutout" | "asset"
|
||||
frame_idx: number
|
||||
element_id?: string | null
|
||||
cutout_id?: string | null
|
||||
@@ -87,69 +85,6 @@ 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
|
||||
@@ -187,15 +122,6 @@ 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
|
||||
@@ -211,38 +137,10 @@ 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"
|
||||
@@ -256,12 +154,6 @@ 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
|
||||
@@ -272,16 +164,6 @@ 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
|
||||
@@ -304,6 +186,15 @@ export interface RuntimeHealth {
|
||||
llm_configured?: boolean
|
||||
auth_configured?: boolean
|
||||
base_url?: string
|
||||
database?: {
|
||||
enabled: boolean
|
||||
url?: string
|
||||
schema_version?: number
|
||||
documents?: number
|
||||
jobs?: number
|
||||
assets?: number
|
||||
error?: string
|
||||
}
|
||||
models?: RuntimeModels
|
||||
}
|
||||
|
||||
@@ -324,9 +215,6 @@ 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
|
||||
@@ -380,7 +268,7 @@ export async function rewriteStoryboardScript(
|
||||
author_intent?: string
|
||||
segments: StoryboardScriptRewriteSegment[]
|
||||
},
|
||||
): Promise<{ items: Array<{ index: number; text: string; text_zh?: string }> }> {
|
||||
): Promise<{ items: Array<{ index: number; text: string }> }> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/script/rewrite`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -477,173 +365,6 @@ 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",
|
||||
@@ -704,16 +425,13 @@ export interface KeyFrame {
|
||||
generated_images?: GeneratedImage[]
|
||||
}
|
||||
|
||||
export type FrameExtractTarget = "transparent_human" | "balanced" | "subject" | "transition" | "expression" | "motion"
|
||||
export type FrameExtractTarget = "random_subject" | "transparent_human" | "balanced" | "subject" | "transition" | "expression" | "motion"
|
||||
export type FrameExtractMode = "replace" | "append"
|
||||
export type FrameExtractQuality = "auto" | "fast" | "accurate" | "ultra"
|
||||
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"
|
||||
@@ -767,63 +485,9 @@ 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
|
||||
@@ -858,46 +522,10 @@ 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
|
||||
@@ -911,7 +539,6 @@ 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
|
||||
@@ -953,6 +580,10 @@ export interface ProductRefStateItem {
|
||||
export interface Job {
|
||||
id: string
|
||||
url: string
|
||||
document_id?: string
|
||||
source_kind?: "tiktok_link" | "upload" | "unknown"
|
||||
workflow_mode?: "feed_recreation" | "uploaded_reference"
|
||||
storage_prefix?: string
|
||||
status: JobStatus
|
||||
progress: number
|
||||
message?: string
|
||||
@@ -967,7 +598,6 @@ export interface Job {
|
||||
storyboard_images?: StoryboardImage[]
|
||||
generated_videos?: GeneratedVideo[]
|
||||
product_refs?: ProductRefStateItem[]
|
||||
subject_agent?: SubjectAgentState
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -976,6 +606,7 @@ export interface BackendHealth {
|
||||
llm_configured: boolean
|
||||
auth_configured?: boolean
|
||||
base_url: string
|
||||
database?: RuntimeHealth["database"]
|
||||
models?: {
|
||||
asr?: string
|
||||
translate?: string
|
||||
@@ -1070,6 +701,9 @@ export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }
|
||||
|
||||
export interface JobSummary {
|
||||
id: string
|
||||
document_id?: string
|
||||
source_kind?: string
|
||||
workflow_mode?: string
|
||||
url: string
|
||||
status: JobStatus
|
||||
progress: number
|
||||
@@ -1085,6 +719,28 @@ export interface JobSummary {
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
id: string
|
||||
title: string
|
||||
source_kind: string
|
||||
workflow_mode: string
|
||||
source_url: string
|
||||
primary_job_id: string
|
||||
status: string
|
||||
storage_prefix: string
|
||||
job_count: number
|
||||
asset_count: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export async function listDocuments(limit?: number): Promise<DocumentSummary[]> {
|
||||
const qs = limit && limit > 0 ? `?limit=${limit}` : ""
|
||||
const res = await fetch(`${API_BASE}/documents${qs}`)
|
||||
if (!res.ok) throw new Error(`listDocuments ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function listJobs(limit?: number): Promise<JobSummary[]> {
|
||||
const qs = limit && limit > 0 ? `?limit=${limit}` : ""
|
||||
const res = await fetch(`${API_BASE}/jobs${qs}`)
|
||||
@@ -1100,8 +756,8 @@ export async function triggerTranscribe(id: string): Promise<Job> {
|
||||
|
||||
export async function analyzeJob(
|
||||
id: string,
|
||||
frames = 12,
|
||||
target: FrameExtractTarget = "balanced",
|
||||
frames = 6,
|
||||
target: FrameExtractTarget = "random_subject",
|
||||
mode: FrameExtractMode = "replace",
|
||||
quality: FrameExtractQuality = "auto",
|
||||
): Promise<Job> {
|
||||
@@ -1123,17 +779,6 @@ 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) {
|
||||
@@ -1287,65 +932,12 @@ 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[]
|
||||
@@ -1467,7 +1059,7 @@ export async function updateElement(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
elementId: string,
|
||||
body: { name_zh?: string; name_en?: string; position?: string; subject_consensus_brief?: string },
|
||||
body: { name_zh?: string; name_en?: string; position?: string },
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, {
|
||||
method: "PATCH",
|
||||
@@ -1519,7 +1111,6 @@ 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[]
|
||||
@@ -1535,7 +1126,6 @@ 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 ?? [],
|
||||
@@ -1559,17 +1149,10 @@ export async function generateSubjectAssets(
|
||||
source_frame_indices?: number[]
|
||||
views?: string[]
|
||||
character_id?: string
|
||||
subject_template_id?: string
|
||||
subject_style?: "transparent_human" | "source_actor" | "cartoon_subject"
|
||||
subject_style?: "transparent_human" | "source_actor"
|
||||
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`, {
|
||||
@@ -1583,17 +1166,10 @@ 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) {
|
||||
@@ -1603,49 +1179,6 @@ export async function generateSubjectAssets(
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function analyzeSubjectAgent(
|
||||
jobId: string,
|
||||
body: {
|
||||
model_bundle: SubjectModelBundle
|
||||
source_frame_indices: number[]
|
||||
},
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-agent/analyze`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw apiError("subjectAgentAnalyze", res.status, txt)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function sendSubjectAgentMessage(
|
||||
jobId: string,
|
||||
body: {
|
||||
model_bundle: SubjectModelBundle
|
||||
source_frame_indices: number[]
|
||||
selected_mode: SubjectAgentState["selected_mode"]
|
||||
selected_traits: string[]
|
||||
requirements_zh: string
|
||||
message: string
|
||||
quantity: number
|
||||
},
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-agent/message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw apiError("subjectAgentMessage", res.status, txt)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteSubjectAsset(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
|
||||
128
内部分享-口播脚本.md
128
内部分享-口播脚本.md
@@ -1,128 +0,0 @@
|
||||
# SKG 营销内容工作台 · 内部分享口播脚本
|
||||
|
||||
> 听众:业务 / 市场同事 · 时长:约 30 分钟 · 风格:口语、实在、不吹
|
||||
> 用法:直接照着念就行。括号里是给你自己的提醒,不用念出来。
|
||||
|
||||
---
|
||||
|
||||
## 开场(约 1 分钟)
|
||||
|
||||
大家好。今天用半小时,讲一个我们最近在做的工具——帮咱们**快速复刻爆款视频**的工作台。
|
||||
|
||||
先把丑话说前面:它**不是要替代人**,也不保证一键出爆款。它的定位很实在——**站在已经被市场验证过的爆款上,快速帮你做出一个 60 分的初稿,剩下的交给人审核和打磨。** 我今天就讲清楚:为什么这么做、怎么用、帮大家省了什么、以后往哪走。
|
||||
|
||||
---
|
||||
|
||||
## ① 背景:为什么做这个东西(约 6 分钟)
|
||||
|
||||
先想一个问题:**一条爆款视频,凭什么火?**
|
||||
|
||||
它火,说明三样东西配合得好——**声音(包含节奏)、文案(精准、不生硬)、画面(和谐、不排斥)**。这三样凑齐了,它才能跑出来。而且关键是:**它已经被市场验证过了**。它能火不是猜的,是真有人看、真有转化。
|
||||
|
||||
那咱们做内容的同事天天在干啥?刷到一条爆款,心里想"这个能跑,咱们也照着做一条,换上 SKG 自己的产品"。这个思路本身没问题——**与其从零拍脑袋想创意、赌它能不能火,不如站在一个已经验证过的爆款上,把它的声音、文案、画面复刻成相似的版本,再把我们的产品穿插进去。** 成功的底子是现成的。
|
||||
|
||||
但问题在于——**人工复刻这件事,又慢又碎又耗人**:
|
||||
|
||||
- 视频得扒下来,文案一句句听、一句句翻;
|
||||
- 文案要重写,还得把产品自然地塞进去,塞硬了观众一眼就出戏;
|
||||
- 画面要重新做,找模特拍、找设计出图,排期好几天;
|
||||
- 最后还要剪、要配。
|
||||
|
||||
每一步都不难,但都得人盯着,特别耗时间。等你吭哧吭哧做完,**爆款的热度可能已经过去了**。
|
||||
|
||||
所以我们的想法很简单:**这条复刻的流水线,让 AI 先替我们快速跑一遍,把初稿做到 60 分。** 人就不用从零开始,直接在 60 分的基础上审核、改、提到能用的水平。
|
||||
|
||||
---
|
||||
|
||||
## ② 应用场景:它到底怎么用(约 8 分钟,建议配演示)
|
||||
|
||||
链路其实就**三步**,特别清楚。(能现场演示就边点边讲)
|
||||
|
||||
**第一步:丢一个爆款链接进去。**
|
||||
看到一条想复刻的视频,把链接贴进来,点开始。剩下的它自己跑。
|
||||
|
||||
**第二步:同时做两件事,把爆款拆成"文案"和"画面"。**
|
||||
|
||||
- 一条线**听声音、出文案**:把原视频的口播、字幕扒出来,外文的自动翻成中文,告诉你它讲了什么、节奏怎么走的。然后在这个基础上重写出**相似但更顺、并且把咱们 SKG 产品穿插进去**的新文案——目标是精准、不生硬。
|
||||
- 另一条线**看视频、复刻画面**:自动从视频里挑出关键画面(就是那些撑起节奏的代表帧),然后照着这些画面**二次创作**,换成我们自己的人物和产品,做出**相似、但和谐不排斥**的新画面。
|
||||
|
||||
**第三步:合起来出片。**
|
||||
把出好的文案,配上复刻好的画面,**生成一段段视频片段,最后剪辑拼成一条完整的视频**。
|
||||
|
||||
你看,本质就是把爆款的三要素——**声音/节奏、文案、画面**——一个个换成"我们的相似版",再装回去。因为底子是验证过的,所以这条相似版大概率也不会差。
|
||||
|
||||
**但这里我必须说实话:** AI 出来的东西,**还得人来审。** 为啥?因为 AI 觉得"有趣""真实"的,跟咱们人觉得有趣、真实的,还是有差别的。机器能快速给你一个 60 分的初稿,但那最后能不能打动人、对不对味,还得靠咱们同事的眼睛把关。**这一步我们不省,也省不了。**
|
||||
|
||||
(演示收尾)所以大家看,它干的是最累的那段——下载、翻译、出文案、复刻画面、拼片;人干的是最值钱的那段——判断和打磨。
|
||||
|
||||
---
|
||||
|
||||
## ③ 价值:帮业务省了哪些事(约 7 分钟)
|
||||
|
||||
先把话说清楚:**它是个辅助工具,不是说有了它,完全不懂的人就能做出片子。**
|
||||
|
||||
为什么这么说?因为出文案、复刻画面、剪视频——每一项其实都是**专业活儿**。文案要写得勾人、产品塞进去不生硬,画面要复刻得像、又和谐,剪辑要卡得上节奏——这些都得懂行的人来判断。AI 能帮你把活干快,但**判断行不行、对不对味,还是得专业的人**。所以它的定位很清楚:**帮懂行的同事提速,不是替代专业。**
|
||||
|
||||
那它实在的价值在哪?
|
||||
|
||||
**第一,把人从重复体力活里捞出来。**
|
||||
下载、转写、翻译、抠画面、出初版图、拼片——这些又重复又零碎的活儿,原来要好几个环节来回对接,现在机器自己跑。懂行的同事不用再耗在搬运上,精力直接花在判断和打磨上。
|
||||
|
||||
**第二,起步快,不用从零。**
|
||||
你不用对着空白发愁,机器先给你一个站在验证过爆款上的初稿,你是在初稿上改、往上提,不是从 0 开始爬。改一版,比从头做一版,快太多了。
|
||||
|
||||
**第三,能快速放量、快速试。**
|
||||
出初稿快,同样的人力可以同时试更多条、更多方向,再快速筛。爆款本来就有运气成分,**多试快筛**,跑出来的概率自然高。
|
||||
|
||||
但说到底,**这些"省时间、起步快、能放量"都只是手段,不是目的。** 我们做这个工具,最终就盯一件事——**达到宣传效果、把流量引进来、最后能正向盈利。** 一条片子做得再快、再好看,如果不引流、不带转化,那也是白做。所以衡量它有没有用,不看"做得多漂亮",看**最后有没有真的帮我们多卖货、多赚钱。**
|
||||
|
||||
---
|
||||
|
||||
## ④ 未来方向:后面想往哪走(约 6 分钟)
|
||||
|
||||
现在能用了,但还有不少要做的。方向其实很明确:
|
||||
|
||||
**一是朝"全自动化"走。**
|
||||
现在链路还需要人在中间盯几个环节。我们想把它越做越顺,最终做到——**机器一路把初稿跑完,人只在最后审核结果**。前面全自动,人只管"过"还是"不过"、哪里要改。
|
||||
|
||||
**二是三个分块各自做得更准、更像。**
|
||||
- 文案:更精准、产品穿插更自然,不生硬;
|
||||
- 画面:复刻得更和谐、更不排斥,人物和产品更稳定;
|
||||
- 声音:节奏卡得更准。
|
||||
这三块每提升一点,初稿的分数就往上走,人要改的就越少。
|
||||
|
||||
**三是攒 SKG 自己的素材和模板。**
|
||||
做得越多,沉淀下来的好文案、好画面、好节奏就越多。以后再复刻,可以直接调我们自己的库,越用越快、越用越像 SKG 的味道。
|
||||
|
||||
总的方向就一句:**让前面越来越自动,人越来越只做最后那道把关。**
|
||||
|
||||
---
|
||||
|
||||
## 收尾(约 1 分钟)
|
||||
|
||||
好,今天就讲这么多。
|
||||
|
||||
再捋一遍:爆款已经被市场验证过,它靠的是**声音、文案、画面**三样;我们做的,就是**站在爆款上,快速把这三样复刻成相似版、换上我们的产品,做出一个 60 分的初稿**;机器干累活、人干判断;以后越来越自动,人只管最后审核。
|
||||
|
||||
它不完美,还需要人把关,但它能让我们**快很多**。欢迎大家会后来试,也欢迎拍砖提需求。
|
||||
|
||||
谢谢大家。(留 Q&A)
|
||||
|
||||
---
|
||||
|
||||
### 时间分配速查
|
||||
| 段落 | 时长 |
|
||||
|---|---|
|
||||
| 开场 | 1 min |
|
||||
| ① 背景 | 6 min |
|
||||
| ② 场景(含演示) | 8 min |
|
||||
| ③ 价值 | 7 min |
|
||||
| ④ 未来 | 6 min |
|
||||
| 收尾 + Q&A | 2 min |
|
||||
| 合计 | 约 30 min |
|
||||
|
||||
### 上台小提醒
|
||||
- 全程的"老实话"是这次分享的底气:**不吹一键爆款,只讲"快速到 60 分 + 人工把关"**,业务同事反而更信。
|
||||
- ② 这段尽量**现场演示**,看一次实际操作比讲十句都管用;演示不了就放录屏。
|
||||
- 有真实案例就插在 ③ 里讲,比讲道理有说服力。
|
||||
- 别念稿,扫一眼要点抬头讲,括号里的提醒别念出来。
|
||||
Reference in New Issue
Block a user