fix: surface resilient subject asset generation

This commit is contained in:
2026-05-18 18:15:45 +08:00
parent cc4c021074
commit 095c6f1c00
8 changed files with 251 additions and 177 deletions

View File

@@ -1,7 +1,7 @@
# SKG TK 二创验证 — 当前状态2026-05-13
# SKG TK 二创验证 — 当前状态2026-05-18
## 一句话
SKG AI 素材生产管线第二条思路TK 链接/上传 → 拆轨 → 抽关键帧5 张+手动加)→ Vision 识别 → 改写文案 → 生图视频 → 合成。**MVP 通到生图,剩余 3 个节点占位**
SKG 信息流广告快速复刻工作台TK 链接/上传 → 下载源视频 → 并行音频解析与 12 张动作/节奏参考帧 → 相似主体 / 产品素材池 → 分镜口播改写 → 首尾帧审核 → 视频候选待开放。当前主流程不直接批量提交视频模型
## 路径 / 端口
- 路径:`~/Projects/business/20260512-20260512-skg-tk-二创验证/`
@@ -15,52 +15,62 @@ key 写在 `api/.env` 的 `LLM_API_KEY`
| 端点 / 字段 | 状态 | 用途 |
|---|---|---|
| 远端 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`。 |
| 远端 ASR | `ASR_MODEL=whisper-1` | 第一优先级音频转写;失败后进本机 ASR。 |
| 本机 ASR | `LOCAL_ASR_MODEL=mlx-community/whisper-tiny` | 二级兜底,优先产出真实逐句时间轴。 |
| ASR 兜底 / 音频分析 | `ASR_FALLBACK_MODEL=gemini-2.5-flash` | 远端和本机都失败后才做多模态 ASR音频画像会读取 `audio.wav` + 转写时间轴,失败则本地估算。后端会拒绝假字幕、重复文本和覆盖率过低结果。 |
| 字幕翻译 | `TRANSLATE_MODEL=gemini-2.5-flash` | 按 ASR 段落补中文;失败时保留英文时间轴,中文可为空。 |
| 画面理解 / brief | `VISION_MODEL=gpt-4o` | 关键帧 Vision 和相似主体非身份化 brief 已切 GPT旧环境若写 `gemini-*` 会自动归一化到 `GPT_TEXT_MODEL`。 |
| 通用改写 / 分镜描述 | `REWRITE_MODEL=gpt-4o` | 已切 GPT旧 Gemini 覆盖值会自动归一化。 |
| 新口播改写 | `AUDIO_REWRITE_MODEL=gpt-4o` | 默认跟随 `REWRITE_MODEL`旧 Gemini 覆盖值会自动归一化。 |
| 产品视角识别 | `PRODUCT_VIEW_MODEL=gpt-image-2` | 产品图批量识别视角、左右 / 上下 / 内外侧、用途和风险。 |
| 所有生图 / 修图 | `gpt-image-2` | 服务端硬锁,无图片模型 fallback覆盖关键帧生图、水印清理、元素提取、主体资产包、产品补角度、首尾帧。 |
| 新口播改写 | `AUDIO_REWRITE_MODEL=gpt-4o` | 默认跟随 `REWRITE_MODEL`失败后依次尝试 `ASR_FALLBACK_MODEL``TRANSLATE_MODEL`,再用本地模板兜底。 |
| 产品视角识别 | `PRODUCT_VIEW_MODEL=gpt-image-2` | 图批量识别;失败后单图重试,再写本地默认视角和风险备注。 |
| 所有生图 / 修图 | `gpt-image-2` | 服务端硬锁,无其他图片模型 fallback覆盖关键帧生图、水印清理、元素提取、主体资产包、产品补角度、首尾帧。 |
| 配音 | `VOICE_PROVIDER=azure_openai` + `AZURE_TTS_MODEL=gpt-4o-mini-tts` | 语音固定 Azure OpenAI TTSMiniMax 不再作为 fallback。后端会按 `AZURE_TTS_PATHS` 依次尝试路径,便于区分路径错误和整条语音服务不可用。 |
| 视频 | `VIDEO_MODEL=seedance` | 当前主流程暂停直接提交;生产通道默认 `ai.skg.com/doubao`Seedance 真实 ID 由 `VIDEO_MODEL_SEEDANCE` 配置。 |
| 视频 | `VIDEO_MODEL=seedance`,别名支持 `kling-omni``veo-3.1-fast` | 当前主流程暂停直接提交;真实 ID 由 `VIDEO_MODEL_SEEDANCE` / `VIDEO_MODEL_KLING` / `VIDEO_MODEL_VEO3` 配置,入口按 `VIDEO_CREATE_PATHS`。 |
**网关后端 = one-hub 多渠道代理**。当前 key 分组叫「纯OpenAI+AWSClaude+Gemini官方」缺 audio 渠道(`gpt-4o-audio-preview` 503 "无可用渠道")和 video 渠道。
## 模型选型(已写入 api/.env
## 模型选型(运行时默认 / 归一化后
```
ASR_MODEL=whisper-1 # ⚠️ 端点 404ASR 还没真跑通
TRANSLATE_MODEL=gemini-2.5-flash # ✅ text 已通
REWRITE_MODEL=gemini-2.5-pro # 占位
VISION_MODEL=gemini-2.5-flash # ✅ 识别已通
IMAGE_MODEL=gemini-3-pro-image-preview # ✅ nano-banana-proi2i 已通
ASR_MODEL=whisper-1
LOCAL_ASR_MODEL=mlx-community/whisper-tiny
ASR_FALLBACK_MODEL=gemini-2.5-flash
TRANSLATE_MODEL=gemini-2.5-flash
GPT_TEXT_MODEL=gpt-4o
VISION_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
AUDIO_REWRITE_MODEL=gpt-4o
IMAGE_MODEL=gpt-image-2
PRODUCT_VIEW_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
VOICE_PROVIDER=azure_openai
AZURE_TTS_MODEL=gpt-4o-mini-tts
VIDEO_MODEL=seedance
VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast
```
## Pipeline 状态(8 节点合并版)
原 10 节点已合并input + download + split 合一translate 合到 transcriptvideogen 和 compose 占位
## Pipeline 状态(9 步工作台版)
当前主入口是信息流复刻工作表,不再是旧 ReactFlow 八节点主画布
| 步 | 节点 | 状态 | 备注 |
|---|---|---|---|
| 1 | **输入·Input**(合并下载+拆分) | ✅ | yt-dlp 真下 + ffmpeg 拆 wav |
| 2 | **关键帧·Keyframes** | ✅ | D 启发式:候选 30 张 → pHash 去重 + Laplacian variance 评分 + 时序分桶 → 5 张;手动加帧 OK |
| 3 | **转录·ASR** | ❌ 阻塞 | SKG 网关 audio 不通;待 IT 开 audio 渠道 / 外部 key |
| 4 | **翻译·Translate** | ❌ 阻塞 | 依赖 ASR |
| 5 | **改写·Rewrite** | ⏳ 占位 | 等用户给产品信息模板 |
| 6 | **生图·Image Gen** | ✅ **刚做完** | nano-banana-pro i2i + 正负 prompt |
| 7 | **生视频·Video Gen** | ⏳ 占位 | sora-2 端点不通 |
| 8 | **合成·Compose** | ⏳ 占位 | 本地 ffmpeg + 字幕 + TTS |
| 1 | **素材输入** | ✅ | TK 链接 / 上传视频;失败素材可重新下载。 |
| 2 | **源视频下载** | ✅ | yt-dlp + cookies 配置;上传视频直接进入 downloaded。 |
| 3 | **音频文案** | ✅ | 拆 `audio.wav`ASR、翻译、讲话人 / 节奏 / 背景音画像。 |
| 4 | **抽帧参考** | ✅ | 下载完成后自动抽 12 张动作/节奏参考帧;支持当前播放点手动补帧。 |
| 5 | **相似主体** | ✅ | GPT 视觉 brief + `gpt-image-2` 文字生图,生成类似但不复刻的人物/透明骨架主体。 |
| 6 | **产品素材池** | ✅ | 不限量上传;`gpt-image-2` 识别视角 / 用途 / 风险,缺角度可补图。 |
| 7 | **分镜文案** | ✅ | 按逐句时间轴生成行,可单段或整片 GPT 改写;保存后写入 storyboard action。 |
| 8 | **画面首尾帧** | ✅ | 用相似主体视图 + 产品素材池生成首帧/尾帧,审核后保存规划。 |
| 9 | **视频候选** | ⏸️ | 历史候选可看;主流程当前暂停直接提交视频模型。 |
## UI 架构(重要)
- **左侧 sidebar**108px 极窄8 个 stage tile 竖排 + DAG 路径分叉表达
- **主区 ReactFlow**8 节点 DAGinput → keyframe/asr → ... → compose
- **点 sidebar tile**:从左滑出 drawer panel粉/紫/橙 Kanban 风格)
- **关键帧 lightbox****embedded 嵌入到 keyframe drawer**(不全屏)—— `<FrameLightbox embedded ... />`drawer 宽度有 expandedFrame 时 760无时 400
- **Input 节点上方**:多视频缩略图浮条 + 「+」加新视频
- **关键帧节点上方**5+ 张缩略图按视频原比例aspect-ratio: width/height
- **缩略图 hover**:弹大图静态(关键帧是垫图素材,不放视频)
- **缩略图点击**:打开 keyframe drawer 内的 lightbox左大图 + 右识别面板)
- 主入口:`web/components/ad-recreation-board.tsx`,左侧素材输入列 + 右侧信息流复刻工作表。
- 工作流条01 素材输入 → 02 源视频下载 → 03 音频文案 → 04 抽帧参考 → 05 相似主体 → 06 产品素材池 → 07 分镜文案 → 08 画面首尾帧 → 09 视频候选。
- 源视频工作区:左侧 9:16 原视频播放器,内置当前点抽帧;右侧音频波形 + 逐句时间轴 + 参考帧池。
- 相似主体:模板库 / 内置形象 / 源视频相似方向;生成结果统一用媒体素材卡,支持 hover 放大、删除、单张重生。
- 分镜工作台:产品素材池、逐句口播、画面规划、首尾帧和历史视频候选在同一纵向工作表里处理。
- 旧 ReactFlow 节点、旧 lightbox、旧 storyboard workbench 底层保留,但当前不作为主入口。
## 数据模型(关键 typescript / pydantic
```typescript
@@ -81,13 +91,15 @@ Job { frames: KeyFrame[] ... }
**前端取帧必须用 `frames.find(x => x.index === activeIndex)` 不能用数组下标**(之前的 bug
## 关键文件
- `web/app/page.tsx` — 多 job state 管理jobs[] + activeJobId8 节点 LAYOUT
- `web/components/dashboard.tsx` — sidebar + drawer + 9 个 Kanban sectioninput/keyframe/asr/translate/rewrite/imagegen/videogen/compose`ImageGenCard` 子组件
- `web/components/lightbox.tsx``FrameLightbox` 支持 `embedded` prop
- `web/app/page.tsx` — 多 job state 管理jobs[] + activeJobId开始后并行触发音频解析和 12 张视觉抽帧
- `web/components/ad-recreation-board.tsx` — 当前主工作台:素材输入、音频结果、参考帧池、相似主体、产品素材池、分镜规划和首尾帧
- `web/components/media-asset-tile.tsx` — 图片 / 视频 / 抽帧 / 产品图 / 主体图 / 首尾帧 / 视频候选统一媒体卡
- `web/components/dashboard.tsx` — 旧 ReactFlow / Kanban 面板,底层保留但当前不作为主入口
- `web/components/lightbox.tsx` — 旧深度素材面板,底层保留
- `web/components/video-lightbox.tsx` — Input 节点点视频缩略图弹的播放器
- `web/components/nodes/index.tsx` — ReactFlow 8 节点定义
- `web/lib/api.ts` — API client
- `api/main.py` — FastAPI 所有端点KeyFrame/GeneratedImage 模型
- `api/main.py` — FastAPI 所有端点,Job/KeyFrame/AudioScript/ProductRef/SubjectAsset/SceneAsset/GeneratedVideo 模型
## 已通的 API 端点
```
@@ -95,13 +107,19 @@ POST /jobs 创建 job链接
POST /jobs/{id}/download/retry TK 链接下载失败后重新下载
POST /jobs/upload 上传视频
GET /jobs/{id} job 状态
POST /jobs/{id}/analyze?frames=5 拆轨+抽帧+ASR 自动一气呵成
POST /jobs/{id}/transcribe 音频提取 + ASR + 翻译 + 讲话人/节奏/背景音分析
POST /jobs/{id}/analyze?frames=12 动作/节奏参考帧抽取
POST /jobs/{id}/frames?t=<sec> 手动按时间戳加帧
POST /jobs/{id}/frames/{idx}/describe ✅ Vision 识别3 次重试 + reasoning_content 兜底)
POST /jobs/{id}/frames/{idx}/generate ✅ 生图i2i / text-only, 含 negative_prompt
GET /jobs/{id}/frames/{idx}/gen/{gen_id}.jpg 生成图二进制
POST /jobs/{id}/frames/{idx}/gen/{gen_id}/select 选用某 gen 给下游
POST /jobs/{id}/assets/product-views/analyze 产品视角 / 用途 / 风险识别
POST /jobs/{id}/assets/product-angle 缺产品角度补图
POST /jobs/{id}/script/rewrite 单段 / 整片新口播改写
POST /jobs/{id}/frames/{idx}/scene-asset 首帧 / 尾帧 / 场景资产生成
GET /jobs/{id}/video.mp4 原视频
GET /jobs/{id}/audio.wav 原音频 wav
GET /jobs/{id}/frames/{idx}.jpg 关键帧 jpg
GET /health
```
@@ -118,6 +136,7 @@ GET /health
9. 关键帧 `index` 是稳定 ID不等于数组下标前端取帧用 `frames.find(x => x.index === idx)`
## 最近变更
- 2026-05-18前端模型链路弹窗、`.project.json``api/README.md` 和本状态文档已按真实后端链路重写:音频三级 ASR、翻译失败行为、音频画像兜底、产品识别重试、相似主体 GPT brief + gpt-image-2 文字生图、脚本改写本地模板兜底、视频主入口暂停。
- 2026-05-18清理个人语音通道残留`/health`、前端类型、环境模板和文档不再暴露相关字段或配置。
- 2026-05-18`VISION_MODEL``REWRITE_MODEL``AUDIO_REWRITE_MODEL` 切到 GPT 默认模型 `gpt-4o`,并加旧 Gemini 环境变量归一化保护。
- 2026-05-18语音通道固定 Azure OpenAI TTS并按 `AZURE_TTS_PATHS` 尝试语音路径。

View File

@@ -1,61 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"hash": "ae81943",
"message": "auto-save 2026-05-16 03:07 (~1)",
"ts": "2026-05-16T03:07:16+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "40064a6",
"message": "auto-save 2026-05-16 03:12 (~1)",
"ts": "2026-05-16T03:13:05+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "38355aa",
"message": "auto-save 2026-05-16 03:18 (~1)",
"ts": "2026-05-16T03:18:56+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "723a564",
"message": "auto-save 2026-05-16 03:24 (~1)",
"ts": "2026-05-16T03:24:46+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "9d37067",
"message": "auto-save 2026-05-16 03:30 (~1)",
"ts": "2026-05-16T03:30:36+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "9f63878",
"message": "auto-save 2026-05-16 03:36 (~1)",
"ts": "2026-05-16T03:36:25+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "2875779",
"message": "auto-save 2026-05-16 03:42 (~1)",
"ts": "2026-05-16T03:42:14+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "6e6fa16",
"message": "auto-save 2026-05-16 03:47 (~1)",
"ts": "2026-05-16T03:48:04+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "c3c7c89",
@@ -3232,6 +3176,59 @@
"message": "auto-save 2026-05-18 17:45 (~3)",
"hash": "92f04f1",
"files_changed": 3
},
{
"ts": "2026-05-18T17:50:39+08:00",
"type": "commit",
"message": "chore: record latest worklog",
"hash": "cc4c021",
"files_changed": 1
},
{
"ts": "2026-05-18T09:52:18Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交chore: record latest worklog",
"files_changed": 1
},
{
"ts": "2026-05-18T17:56:44+08:00",
"type": "commit",
"message": "auto-save 2026-05-18 17:56 (~3)",
"hash": "d8780e5",
"files_changed": 3
},
{
"ts": "2026-05-18T18:02:08+08:00",
"type": "commit",
"message": "auto-save 2026-05-18 18:02 (~3)",
"hash": "59d7ec3",
"files_changed": 3
},
{
"ts": "2026-05-18T10:02:18Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 2 项未提交变更 · 最近提交auto-save 2026-05-18 18:02 (~3)",
"files_changed": 2
},
{
"ts": "2026-05-18T18:07:32+08:00",
"type": "commit",
"message": "auto-save 2026-05-18 18:07 (~7)",
"hash": "c1c4106",
"files_changed": 7
},
{
"ts": "2026-05-18T10:12:18Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 4 项未提交变更 · 最近提交auto-save 2026-05-18 18:07 (~7)",
"files_changed": 4
},
{
"ts": "2026-05-18T18:12:58+08:00",
"type": "commit",
"message": "auto-save 2026-05-18 18:12 (~4)",
"hash": "ebfc507",
"files_changed": 4
}
]
}

View File

@@ -8,12 +8,6 @@
"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",
@@ -39,7 +33,7 @@
"type": "web_login"
}
],
"description": "SKG 信息流广告快速复刻第一步:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后优先解析原音频,提取原文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜、元素生成和视频合成暂保留为后续能力,不作为当前开始流程的默认动作。",
"description": "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案/字幕、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效;视觉路自动抽 12 张动作/节奏参考帧,供生成相似主体、产品素材池、分镜口播和首尾帧审核。当前主流程暂停直接提交视频模型,先保存规划和首尾帧。",
"kind": "app",
"name": "SKG Marketing Studio / SKG 营销内容工作台",
"ownership": "company",
@@ -64,7 +58,7 @@
"username": "skg"
},
"stack": [
"Next.js + Python(yt-dlp/ffmpeg) + OpenAI-compatible LLM + GPT Image + Azure OpenAI TTS + Seedance"
"Next.js + Python(yt-dlp/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance/Kling/Veo video gateway"
],
"status": "active",
"urls": [

View File

@@ -1,6 +1,6 @@
# SKG TK 二创 API
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/英文 SKG 产品介绍文案 + Azure OpenAI 英文配音管线。
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/音频画像、抽帧、GPT 图像生成/修图、Azure OpenAI TTS 预留和视频候选预留管线。
## 启动
@@ -18,21 +18,23 @@ uvicorn main:app --host 127.0.0.1 --port 4291
## 路由
- `GET /health` — 健康检查 + 配置状态
- `POST /jobs` `{url}` — 创建 job后台下载源视频,视频就绪后可手动解析或提取音频
- `POST /jobs` `{url}` — 创建 job后台下载源视频;前端“开始分析”会在视频就绪后自动启动音频解析和视觉抽帧
- `POST /jobs/{id}/download/retry` — TK 链接下载失败后重试下载;上传视频任务不能重下载
- `GET /jobs/{id}` — 当前状态 + 产物;若原始音轨已拆出,会返回 `source_audio_url`
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 英文产品介绍文案;文案长度按原音频时长估算,配置 Azure OpenAI TTS 后从 Azure 音色池生成配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,可与抽帧并行,不自动触发
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 中文翻译 + 讲话人 / 节奏 / 背景音分析;当前第一步不默认生成 SKG 新口播或 TTS 配音
- `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 张关键帧0-9
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端音频波形和多模态音频分析使用
- `GET /jobs/{id}/audio-script.mp3`后续新配音阶段保留的 Azure OpenAI TTS 文件
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张参考帧;当前主流程自动抽 12 张动作 / 节奏参考帧,也支持手动按当前播放点补帧
## Mock 模式
未设 `LLM_API_KEY` 时,转录走本地 mock便于 UI 联调;未设 `AZURE_OPENAI_API_KEY` 且无法复用 `LLM_API_KEY` 时只生成改写文案,不生成配音文件
未设 `LLM_API_KEY` 时,转录走本地 mock便于 UI 联调;未设 `AZURE_OPENAI_API_KEY` 时,后续 TTS 文件不会生成,但不影响当前第一步音频解析
## 依赖
- `ffmpeg` 系统二进制(拆轨 / 抽帧)
- `yt-dlp` 系统二进制(也可走 Python 包)
- OpenAI 兼容 LLM 网关ASR / 翻译 / 文案改写);如果 `/audio/transcriptions` 不可用,会`ASR_FALLBACK_MODEL` 走 Gemini 多模态音频识别
- Azure OpenAI TTS英文产品介绍文案配音使用 `AZURE_OPENAI_API_KEY` 或回退复用 `LLM_API_KEY`;默认音色池 `alloy,verse,shimmer`
- 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` 依次尝试语音路径)

View File

@@ -4505,6 +4505,8 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
)
models = [GPT_IMAGE_MODEL]
generated: list[SubjectAsset] = []
generation_errors: list[str] = []
first_generation_error: RuntimeError | None = None
try:
for view, view_label in _subject_view_labels(req.subject_kind, req.views):
closeup_view = view in {"bust", "back_detail", "bust_front", "bust_left_45", "bust_right_45", "back_neck_detail"} or "detail" in view
@@ -4572,7 +4574,11 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
raise RuntimeError("subject asset edit reference image missing")
img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1280)
except RuntimeError as e:
raise HTTPException(_image_error_status(e), f"subject asset {view} failed: {e}")
if first_generation_error is None:
first_generation_error = e
generation_errors.append(f"{view_label}: {e}")
print(f"[subject assets] view failed job={job_id} view={view} error={e}", flush=True)
continue
asset_id = f"subject_{idx:03d}_{element_id}_{view}_{uuid.uuid4().hex[:8]}"
out_path = job_dir(job_id) / "assets" / f"{asset_id}.jpg"
@@ -4596,6 +4602,11 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
try: p.unlink()
except OSError: pass
if not generated:
if first_generation_error:
raise HTTPException(_image_error_status(first_generation_error), f"subject assets failed: {'; '.join(generation_errors[:3])}")
raise HTTPException(500, "subject assets failed: no images generated")
src = _source_frame_path(job_id, idx)
new_frames = []
for f in job.frames:
@@ -4614,7 +4625,13 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
current_assets = [asset for asset in current_assets if asset.view not in replaced_views]
e.subject_assets = current_assets + generated
new_frames.append(f)
update(job, frames=new_frames, message=f"主体资产包生成完成 · {el.name_zh} · {len(generated)}")
if generation_errors:
msg = f"主体资产包部分生成完成 · {el.name_zh} · {len(generated)} 张,失败 {len(generation_errors)}"
error_msg = "".join(generation_errors[:3])
else:
msg = f"主体资产包生成完成 · {el.name_zh} · {len(generated)}"
error_msg = ""
update(job, frames=new_frames, message=msg, error=error_msg)
return job

File diff suppressed because one or more lines are too long

View File

@@ -38,6 +38,7 @@ import {
generateProductAngleAsset,
generateSubjectAssets,
generatedImageUrl,
getJob,
getRuntimeHealth,
hasCutout,
listCharacterLibrary,
@@ -570,9 +571,9 @@ function audioModelTrace(models?: RuntimeModels): ModelTraceSpec {
title: "音频解析",
model: modelList([models?.asr, models?.translate, models?.asr_fallback]),
chain: [
`ASR 转写:优先 ${modelValue(models?.asr)}失败后尝试本机 ${modelValue(models?.local_asr)},再回退 ${modelValue(models?.asr_fallback)}`,
`字幕翻译:${modelValue(models?.translate)} 输出中文逐句时间轴`,
`讲话人 / 节奏 / 背景音:${modelValue(models?.asr_fallback)} 读取 audio.wav 做多模态音频分析`,
`ASR 转写:优先 ${modelValue(models?.asr)}失败后尝试本机 ${modelValue(models?.local_asr)};仍失败才回退 ${modelValue(models?.asr_fallback)},并拒绝假字幕/重复时间轴`,
`字幕翻译:${modelValue(models?.translate)} 按 ASR 段落输出中文;失败时保留原文时间轴,中文可为空`,
`讲话人 / 节奏 / 背景音:${modelValue(models?.asr_fallback)} 读取 audio.wav + 转写时间轴做多模态分析;失败时用本地时长/段落估算兜底`,
],
note: "点击“解析音频”后触发;开始任务下载完成后也会自动走这条链路。",
}
@@ -583,9 +584,10 @@ function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
title: "产品视角识别 / 补图",
model: modelList([models?.product_view, models?.image]),
chain: [
`批量视角识别:${modelValue(models?.product_view)} 一次读取同一产品多张图,标注视角、左右、上下、用途和风险`,
`缺角度补图:${imageModelChain(models)} 读取最相关的多张已上传参考图,按同一肩颈按摩仪结构补齐缺失视角`,
"前端只保存标注和 AI 补图结果;后续生成视频时每条最多挑 6 张相关产品图",
`批量视角识别:${modelValue(models?.product_view)} 多图读取同一产品素材,标注视角、佩戴者左右、上下、内外侧、用途和风险`,
"识别兜底:批量失败会按单图重试;仍失败或文件缺失时写入本地默认视角,并在 risk/note 标明兜底原因",
`缺角度补图:${imageModelChain(models)} 走 /images/edits最多读取 6 张已上传参考图补齐缺失视角;失败保留重试入口,不自动换模型`,
"前端只保存标注和 AI 补图结果;后续首尾帧/视频规划每条最多挑 6 张相关产品图",
],
note: "上传产品图、重新识别、缺视角重试都会使用这组模型链路。",
}
@@ -594,11 +596,11 @@ function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyle: SubjectStyleMode): ModelTraceSpec {
return {
title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似普通真人主体",
model: subjectImageModelChain(models),
model: modelList([models?.vision, models?.subject_image]),
chain: [
"参考策略:先用视觉模型把关键帧/模板转成非身份化文字 brief,生图请求不再上传参考图",
`视觉 brief${modelValue(models?.vision)} 把关键帧/模板转成非身份化文字 brief;失败时继续用用户方向和模板文字`,
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张生成高清图,视图数量由“全部/常用/自定义”决定`,
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
],
note: "这是生成类似但创新的主体,不是复制、抠出或复刻源视频人物身份;内置形象也只作为方向参考。",
@@ -611,8 +613,8 @@ function scriptRewriteModelTrace(models?: RuntimeModels): ModelTraceSpec {
model: modelList([models?.audio_rewrite, models?.asr_fallback, models?.translate]),
chain: [
`主改写:${modelValue(models?.audio_rewrite)} 根据原文案、当前分镜、作者想法生成新口播`,
`失败回退:依次尝试 ${modelValue(models?.asr_fallback)}${modelValue(models?.translate)}`,
"返回结果只写入当前分镜文案编辑框;生成视频时再把当前文案写入分镜 action",
`模型回退:依次尝试 ${modelValue(models?.asr_fallback)}${modelValue(models?.translate)};全部失败时用本地模板保留分镜可编辑`,
"返回结果只写入当前分镜文案编辑框;点击保存规划后才写入 frame.storyboard.action",
],
}
}
@@ -625,8 +627,9 @@ function videoModelTrace(models: RuntimeModels | undefined, model: string): Mode
`前端选择:${model}`,
`后端解析:${resolveVideoModelLabel(models, model)}`,
`服务商:${modelValue(models?.video_provider)} · ${modelValue(models?.video_base_url)}`,
"输入:已确认的首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
"输出:异步候选视频,完成后回填到对应分镜行",
"当前主工作台暂停直接提交视频;旧入口误触也会被页面层保护",
"开放后输入会包含已确认首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
"输出为异步候选视频完成后回填到对应分镜行Sora 已停用",
],
}
}
@@ -2144,7 +2147,7 @@ function SourceReferenceBuildPanel({
onJobUpdate: (job: Job) => void
runtimeModels?: RuntimeModels
}) {
const [subjectBusy, setSubjectBusy] = useState(false)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar")
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
@@ -2220,6 +2223,7 @@ function SourceReferenceBuildPanel({
? `${selectedCharacter.name} · 模板参考`
: "源视频关键帧 · 相似创新"
const templateRequired = subjectMode === "template" && !selectedSubjectTemplate && !selectedCharacter
const subjectBusy = !!subjectBusyFor
const generationCtaLabel = subjectMode === "template"
? `用模板生成 ${selectedSubjectViews.length} 张主体视图`
: `从源视频创新生成 ${selectedSubjectViews.length} 张主体视图`
@@ -2265,13 +2269,14 @@ function SourceReferenceBuildPanel({
}
const baseFrame = subjectReferenceFrames[0]
if (!baseFrame) return
setSubjectBusy(true)
const requestJobId = job.id
setSubjectBusyFor({ jobId: requestJobId, jobLabel: shortId(requestJobId), viewCount: selectedSubjectViews.length })
try {
let workingJob = job
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
let element = workingFrame.elements?.find(isSimilarActorElement)
if (!element) {
workingJob = await addElement(job.id, baseFrame.index, {
workingJob = await addElement(requestJobId, baseFrame.index, {
name_zh: selectedTemplatePrompt
? `相似透明骨架主体 · ${selectedTemplatePrompt.name}`
: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角",
@@ -2288,7 +2293,7 @@ function SourceReferenceBuildPanel({
}
if (!element) throw new Error("similar subject element missing")
const updated = await generateSubjectAssets(job.id, baseFrame.index, element.id, {
const updated = await generateSubjectAssets(requestJobId, baseFrame.index, element.id, {
subject_kind: "living",
subject_style: subjectStyle,
reconstruction_mode: "similar",
@@ -2304,9 +2309,12 @@ function SourceReferenceBuildPanel({
onJobUpdate(updated)
toast.success(`相似主体 ${selectedSubjectViews.length} 张高清白底图已生成`)
} catch (e) {
try {
onJobUpdate(await getJob(requestJobId))
} catch { /* keep original error visible */ }
toast.error("相似主体重构失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubjectBusy(false)
setSubjectBusyFor(null)
}
}
@@ -2438,7 +2446,8 @@ function SourceReferenceBuildPanel({
))}
</div>
<div className={`transition ${subjectMode === "source_similar" ? "pointer-events-none opacity-38 grayscale" : ""}`}>
{subjectMode === "template" ? (
<div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2">
{subjectTemplateLibrary.map((template) => {
const preview = characterPreviewImage(template)
@@ -2494,6 +2503,11 @@ function SourceReferenceBuildPanel({
})}
</div>
</div>
) : (
<div className="rounded-md border border-cyan-200/18 bg-cyan-300/[0.06] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/62">
</div>
)}
{subjectMode === "template" && (selectedSubjectTemplate?.images?.length || selectedCharacter?.images?.length) ? (
<div className="mt-2 flex gap-1.5 overflow-x-auto pb-0.5">
@@ -2545,6 +2559,45 @@ function SourceReferenceBuildPanel({
</div>
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
</div>
{subjectBusyFor ? (
<div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
{subjectBusyFor.jobLabel} {subjectBusyFor.viewCount}
</div>
) : null}
{visibleActorAssets.length ? (
<div className="mb-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
{visibleActorAssets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
return (
<MediaAssetTile
key={asset.id}
src={subjectAssetUrl(job, asset)}
href={subjectAssetUrl(job, asset)}
alt={asset.label || asset.view}
label={asset.label || asset.view || "主体视图预览"}
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
className="aspect-[9/16] w-20 bg-white 2xl:w-24"
objectFit="contain"
title={asset.label || asset.view}
actions={[{
key: "regen",
label: "重新生成这一张",
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy,
onClick: () => void regenerateSubjectAsset(asset),
}]}
onDelete={() => void deleteActorAsset(asset)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy}
deleteLabel="删除这一张"
/>
)
})}
</div>
) : null}
<div className="grid gap-2 xl:grid-cols-[auto_auto_minmax(220px,1fr)_auto] xl:items-start">
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
@@ -2595,7 +2648,7 @@ function SourceReferenceBuildPanel({
className="inline-flex h-9 min-w-[170px] items-center justify-center gap-1 rounded-md bg-white px-3 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{generationCtaLabel}
{subjectBusyFor ? `生成中 · ${subjectBusyFor.jobLabel}` : generationCtaLabel}
</button>
</div>
@@ -2623,45 +2676,13 @@ function SourceReferenceBuildPanel({
</div>
) : null}
{visibleActorAssets.length ? (
<div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
{visibleActorAssets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
return (
<MediaAssetTile
key={asset.id}
src={subjectAssetUrl(job, asset)}
href={subjectAssetUrl(job, asset)}
alt={asset.label || asset.view}
label={asset.label || asset.view || "主体视图预览"}
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
className="aspect-[9/16] w-20 bg-white 2xl:w-24"
objectFit="contain"
title={asset.label || asset.view}
actions={[{
key: "regen",
label: "重新生成这一张",
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy,
onClick: () => void regenerateSubjectAsset(asset),
}]}
onDelete={() => void deleteActorAsset(asset)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy}
deleteLabel="删除这一张"
/>
)
})}
</div>
) : (
{!visibleActorAssets.length ? (
<div className="mt-2 rounded border border-dashed border-white/12 px-2 py-2 text-[10.5px] leading-snug text-white/32">
{subjectMode === "template"
? "先选主体模板,再生成新主体视图;模板只作为文字化创意方向,不再作为强参考图复制。"
: "直接使用关键帧的文字化主体特征生成创新主体;后端不会上传源图给生图端点。"}
</div>
)}
) : null}
</div>
</div>
</div>

View File

@@ -673,7 +673,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
{key === "videogen" && (
<>
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Seedance / Kling / Veo 3">
<div className="text-[11px] text-[var(--text-soft)]"> /v1/videos ID </div>
<div className="text-[11px] text-[var(--text-soft)]"> VIDEO_CREATE_PATHS ID </div>
</KanbanCard>
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
<div className="text-[11px] text-[var(--text-soft)]"> · API key</div>