Compare commits

...

61 Commits

Author SHA1 Message Date
CI Bot 1827a1fa49 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 38s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m7s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
AI Code Review / AI Code Review (pull_request) Successful in 6m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 9m34s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
2026-09-10 09:20:20 +00:00
saas-backend 150dc17273 fix(ai-avatar): 修复智能封面接口 502,增大抽帧等待时间+连接池下载+video_url 重签
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m23s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 1m34s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
问题根因:
1. MediaKit 抽帧轮询参数过短(poll_interval=2s, max_poll_attempts=5,最多等 10s),大视频或排队场景下 MediaKit 尚未完成即放弃
2. 帧图片串行下载,每次新建 TCP 连接(httpx.get),5帧×15s 最坏 75s,加上 persist 再下载 30s,总耗时可达 105s
3. 前端传入的 video_url 可能是 OSS 私有桶裸 URL,MediaKit 无法访问导致抽帧失败

修复:
- MediaKit 抽帧参数:poll_interval 2s→3s,max_poll_attempts 5→20(最多等 60s),max_retries 0→1
- 帧下载改用 httpx.Client 连接池复用,减少连接建立开销
- 新增 _sign_video_url_for_mediakit:自动检测自家 OSS URL 并重签 7 天有效期
- 路由层增加 try/except 包裹 generate_smart_cover,异常时返回 fallback 而非 502
- 增加关键路径日志

测试:新增 3 个单测(video_url 重签、外部 URL 透传、轮询参数校验),共 15 测试全过
2026-09-10 16:23:16 +08:00
xiaoxia b5b324d94e chore: 删除前端冗余代码 (-1215行) #1831 (#1836)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 54s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m17s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate - Style (push) Successful in 2m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Validate - Security (push) Successful in 5m21s
CI/CD Pipeline / Unit Tests (push) Successful in 7m37s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-10 12:47:29 +08:00
xiaoxia 9fff6ff8e2 feat(ai-avatar): AI数字人页面拆分为两步
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 32s
CI/CD Pipeline / Validate - Style (push) Successful in 1m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m10s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Validate - Security (push) Successful in 4m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m13s
CI/CD Pipeline / Unit Tests (push) Successful in 7m59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- 步骤1:出镜视频 / 配音库 / 文案
- 步骤2:对口型预览(含插入画面) / 标题配置 / 封面&生成
- 新增 PanelScript.tsx(文案面板)
- 新增 PanelLipsyncPreview.tsx(对口型预览面板)
- 删除 PanelScriptAndLipsync.tsx(已拆分)
- 补充对应 CSS 样式
2026-09-10 12:28:40 +08:00
xiaoxia e679b54ebc Merge PR #1827: AI数字人v3 完整联调版(TTS直生+smart-cover+前后端冲突解决)(#1827)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m25s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Validate - Style (push) Successful in 2m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m11s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Unit Tests (push) Successful in 8m33s
CI/CD Pipeline / Validate - Security (push) Successful in 18m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-09 23:45:16 +08:00
CI Bot 01ae42edc6 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m26s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m7s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m59s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m33s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 21m10s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m19s
2026-09-09 15:21:46 +00:00
deploy-agent a199fb0fc4 test(lipsync): 采用后端解冲突版 test_lipsync_routes.py(v3 submit_synthesize_task 契约)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 44s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m39s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m0s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
4efa71ec 合并时 tests/unit/test_lipsync_routes.py 误取了 develop #1809 版本
(synthesize_speech/route._resolve_voice_id/断言 audio_url 移除),与合并后
v3 #1822 双模式实现不一致,导致 7 个单测失败。改用后端解冲突分支
backend/ai-avatar-merge-1827 (0e994072) 中已适配 v3 的版本:
mock submit_synthesize_task、emotion 兴奋→excited、双模式 audio_url 保留。
本地验证该文件 28 passed、lipsync/avatar/tts 相关 79 passed。
2026-09-09 23:19:19 +08:00
CI Bot 4efa71ec36 merge: 合并 develop 解决 AI数字人冲突(v3新契约 + develop增强 双保留)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
前端7文件(基于 v3 契约为主干融合 develop 增强,#1828口径):
- 对口型模式A: video_url+voice_id+script_text+speed+emotion(英文枚举,禁传video_asset_id)
- title_config 按 build_title_drawtext_filter 真实字段映射(text/font_size/font_color/position默认bottom/enabled/bold/stroke/shadow), 不传 titles[]/fontSize/frame
- 智能封面 POST /ai-avatar/render/smart-cover; 语速情绪全链路透传
- 保留 develop: MediaKit中间状态/非阻塞轮询(running中间态)/标题TextArea实时预览+标题库(TitleLibraryModal)
后端4文件取 backend/ai-avatar-merge-1827(0e994072) 已解版本(双模式/TTS直生/emotion透传/中间状态)
tsc ai-avatar 零错误, prettier 全合规
2026-09-09 23:02:15 +08:00
deploy-agent f72d640e8c style(ai-avatar): prettier 格式化 PanelTitleConfig/TitleLibraryModal
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m46s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m14s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 5m21s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m40s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 24m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
修复 #1828 前端对接代码的 prettier check 失败,仅格式调整无逻辑改动。
2026-09-09 21:11:17 +08:00
xiaoxia f4cac1dc6e feat(ai-avatar): 前端对接 #1826 后端契约 — 模式A对口型/语速情绪/智能封面/标题字段映射 (#1828)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m26s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m32s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 6m29s
AI Code Review / AI Code Review (pull_request) Successful in 7m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m28s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
2026-09-09 21:00:46 +08:00
xiaoxia 9f94ef24c1 test(ai-avatar): 同步 #1822 过期测试断言
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 53s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m7s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m25s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m58s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m14s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m40s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 28m21s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
- test_tts_preview: preview 路由现在总是透传 emotion(默认空串),
  4 处 synthesize_speech 断言补充 emotion=''
- test_ai_avatar_render_service: script_id 改为可选后,
  空值应规范化为空串而非抛 ValueError;lipsync_job_id 仍必填

全量 14967 passed, 0 failed
2026-09-09 20:31:58 +08:00
CI Bot 7adcf28b76 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m41s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m8s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m27s
AI Code Review / AI Code Review (pull_request) Successful in 6m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m35s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-09-09 12:11:22 +00:00
xiaoxia 6e99e64f73 feat(ai-avatar): #1822 TTS直生对口型 + 语速/情绪透传 + MediaKit智能封面 (#1826)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m41s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m51s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m58s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m0s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
TTS直生/情绪语速透传/智能封面/契约文档/script_id可选 #1797 #1822
2026-09-09 20:03:33 +08:00
xiaoxia 4d09bd630e fix(ai-avatar): 契约文档标题字段纠正 + script_id 改为可选
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m25s
- 文档 §5/§6:标题是单个 title_config dict(非 titles[] 数组),字段以
  build_title_drawtext_filter() 为准:text/content、font_size/size、
  font_color/color、position(top/center/bottom/custom)、pos_x/pos_y、bold、
  stroke、shadow、enabled;删除不存在的 titles[]/fontSize/frame/start/end 描述
- script_id 改为可选:手动输入文案/TTS 直生场景不关联文案库条目,留空不校验,
  避免手动文案用户被 ScriptNotFound 卡住;选了文案库时仍做归属校验
- 迁移 074:ai_avatar_render_jobs.script_id 默认空串
- 新增 schema 单测(script_id 空/空白/title_config 单 dict),94 测试全绿

Refs #1797 #1826
2026-09-09 19:55:01 +08:00
xiaoxia 97359ef77f fix: 在 worker Dockerfile 中添加 HEALTHCHECK 指令
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 7s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 2m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Style (push) Successful in 2m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m23s
CI/CD Pipeline / Unit Tests (push) Successful in 9m8s
CI/CD Pipeline / Validate - Security (push) Successful in 9m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
将健康检查命令直接写入 Docker 镜像,确保 Watchtower 重建容器时使用正确的健康检查。
修复 Worker 容器持续显示 unhealthy 的问题。
2026-09-09 19:16:28 +08:00
xiaoxia 8052f245ec fix: 在 worker Dockerfile 中添加 HEALTHCHECK 指令
将健康检查命令直接写入 Docker 镜像,确保 Watchtower 重建容器时使用正确的健康检查。
修复 Worker 容器持续显示 unhealthy 的问题。
2026-09-09 19:15:46 +08:00
xiaoxia a27ed596b4 feat(ai-avatar): TTS直生对口型 + 语速/情绪透传 + MediaKit智能封面 + 契约文档
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m52s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
- 对口型支持 TTS 直生模式:POST /lipsync/jobs 传 voice_id+script_text(+speed/emotion),
  后端内部调 CosyVoice 合成音频→转存OSS→提交 MediaKit;保留 audio_url 直接音频模式
- cosyvoice_service: 新增 emotion 参数 + normalize_emotion(中文 自然/兴奋/沉稳/亲切
  映射 natural/excited/calm/friendly),空值不透传
- TTS 链路 speed/emotion 全链路透传:schema→use_case(metadata)→workflow(单段/分段/重合成)
  →submit_synthesize_task(rate/emotion);preview 即时试听同步
- lipsync refresh: 中间状态(running/processing)同步DB;completed 输出视频转存自家OSS防过期
- 修复 lipsync/ai-avatar 路由 current_user.id → current_user.user.id(AuthenticatedUser 无 .id)
- 新增 POST /ai-avatar/render/smart-cover 独立封面接口:复用 MediaKit extract_frames
  + cover_frame_scorer 评分选最佳帧(非 FFmpeg 首帧),渲染管线封面同样优先智能选帧
- 迁移 073: lipsync_jobs 增加 voice_id/script_text/speed/emotion 列,audio_url 改可空
- docs/ai-avatar-api-contract-1822.md: 前后端接口契约 + title_config 字段清单
- 新增 11 个单测(情绪归一化/payload透传/TTS直生/中间状态/智能封面),相关 82 测试全绿

Refs #1797 #1822
2026-09-09 19:13:03 +08:00
xiaoxia 1bd0f0c1ae Merge pull request 'fix: Worker health check 扫描全部 /proc 进程修复 unhealthy 误判' (#1825) from fix/worker-healthcheck-ci-e2e into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m37s
CI/CD Pipeline / Validate - Style (push) Successful in 3m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m27s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 19:09:14 +08:00
xiaoxia 54defa4429 fix: Worker health check 扫描全部 /proc 进程修复 unhealthy 误判
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m1s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 13m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 11s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m53s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m33s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
问题:Worker 容器内无 pgrep/ps 工具,旧 health check 使用
'grep -q celery /proc/1/cmdline' 检查 PID 1 的 cmdline,
但 PID 1 是 entrypoint-worker.sh(bash 脚本),不含 celery 关键字,
导致 health check 永远返回 unhealthy。

同时部署脚本中 sh -c 嵌套引号导致 $pid 变量被外层 shell 提前展开为空。

修复:改用 grep -lq celery /proc/[0-9]*/cmdline 扫描所有进程 cmdline,
找到任意 celery 进程即判定 healthy。无需变量、无嵌套 shell、无 pgrep 依赖。

影响文件:
- infra/docker/compose.yml
- infra/docker/deploy-staging-registry.sh
- infra/docker/deploy-production-registry.sh
2026-09-09 18:42:17 +08:00
CI Bot da2302b7ad fix: 移除 PanelTitleConfig 中未使用的 getFontFamily 导入
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m11s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Style (push) Successful in 2m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m26s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m0s
CI/CD Pipeline / Validate - Security (push) Successful in 7m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m14s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-09 18:35:45 +08:00
CI Bot c06c095b74 fix: 修复 PanelTitleConfig 未使用导入导致的 TS6133 编译错误,完整实现 TextArea + TitleLibraryAutoComplete
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Integration Tests (push) Successful in 1m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m10s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m44s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m5s
CI/CD Pipeline / Validate - Security (push) Successful in 6m51s
CI/CD Pipeline / Unit Tests (push) Successful in 8m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-09 18:26:27 +08:00
CI Bot 5f54ac3707 feat: 标题输入改用TextArea多行+标题库自动补全,标题预览增大字号加描边阴影
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Integration Tests (push) Successful in 1m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m43s
CI/CD Pipeline / Validate - Style (push) Successful in 1m50s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m50s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m35s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 5m59s
CI/CD Pipeline / Validate - Security (push) Successful in 7m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-09 18:16:28 +08:00
CI Bot 02b82466da fix: 使用 getFontFamily 替代原始字体字符串,修复 TS6133
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Validate - Style (push) Successful in 1m41s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m50s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m0s
CI/CD Pipeline / Unit Tests (push) Successful in 6m32s
CI/CD Pipeline / Validate - Security (push) Successful in 7m36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-09 17:42:23 +08:00
CI Bot 005cff1ce5 fix: 实现标题实时预览覆盖层,修复 titleConfig 未使用 TS6133 错误
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Integration Tests (push) Successful in 1m9s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m1s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m9s
CI/CD Pipeline / Validate - Style (push) Successful in 2m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m5s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m48s
CI/CD Pipeline / Unit Tests (push) Successful in 7m41s
CI/CD Pipeline / Validate - Security (push) Successful in 8m25s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-09 17:32:12 +08:00
CI Bot b1930e594d fix: 修复 TypeScript 编译错误(移除未使用 import、补充 props 类型、修正 TitleItem 字段名)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Integration Tests (push) Successful in 1m3s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m42s
CI/CD Pipeline / Validate - Style (push) Successful in 1m45s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m6s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m6s
CI/CD Pipeline / Unit Tests (push) Successful in 7m17s
CI/CD Pipeline / Validate - Security (push) Successful in 9m21s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-09 17:19:52 +08:00
CI Bot 8467f3fa94 fix: 修复 PanelTitleConfig.tsx 重复return语法错误 + lipsync.py B904 ruff告警
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m12s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m26s
CI/CD Pipeline / Validate - Style (push) Successful in 2m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m49s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m18s
CI/CD Pipeline / Unit Tests (push) Successful in 8m9s
CI/CD Pipeline / Validate - Security (push) Successful in 9m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-09 17:07:50 +08:00
CI Bot 93a4c1b639 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Build Staging API Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m5s
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Failing after 4m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Unit Tests (push) Successful in 8m13s
CI/CD Pipeline / Validate - Security (push) Successful in 9m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-09 08:23:55 +00:00
xiaoxia a2f43926a5 feat: 克隆音色情绪参数传递(natural/excited/calm/friendly)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 17s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m15s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m44s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m49s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 16:19:27 +08:00
saas-backend-agent eca530bd6f feat: CosyVoice 语音合成支持 emotion 情绪参数(natural/excited/calm/friendly)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m36s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m38s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m47s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 16:15:49 +08:00
CI Bot 4556dff14c style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 08:05:03 +00:00
xiaoxia 36df99d106 feat: 对口型视频编辑功能增强(标题库选择+实时预览+封面自动获取)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Integration Tests (push) Successful in 1m31s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m38s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 16:03:02 +08:00
saas-backend-agent 54deb9b549 fix: MediaKit 中间状态(running/processing)同步到 DB,不再卡在 submitted
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / Integration Tests (push) Successful in 1m35s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m46s
CI/CD Pipeline / Validate - Style (push) Failing after 3m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m30s
CI/CD Pipeline / Unit Tests (push) Successful in 8m25s
CI/CD Pipeline / Validate - Security (push) Successful in 8m58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-09 15:24:07 +08:00
CI Bot 288e0760df style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m9s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 07:14:01 +00:00
saas-backend-agent 0c76967453 fix: GET /lipsync/jobs/{id} 加调试日志捕获 502 根因
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m7s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 15:11:29 +08:00
xiaoxia 8c7b1ff16a fix: 对口型轮询加调试日志,failed 状态显示错误信息
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m15s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m10s
CI/CD Pipeline / Validate - Style (push) Successful in 2m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m4s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 15:04:43 +08:00
saas-backend-agent 44f973c168 fix: GET /lipsync/jobs/{id} 改为非阻塞 — 先返回缓存,后台异步刷新
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m51s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m49s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
上次修复让 GET 同步调 MediaKit,MediaKit 偶尔慢 60s 直接拖死轮询。
改为 BackgroundTasks 异步刷新:响应立即返回 DB 缓存状态,
MediaKit 刷新在 response 发送后后台执行,不阻塞前端。
最坏情况:某次轮询拿到旧状态,3 秒后下次轮询拿到新状态。
2026-09-09 14:58:53 +08:00
saas-backend-agent a17726c963 fix: GET /lipsync/jobs/{id} 非终态自动刷新 MediaKit 状态
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m6s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m39s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m47s
CI/CD Pipeline / Validate - Style (push) Successful in 2m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m22s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Validate - Security (push) Successful in 6m54s
CI/CD Pipeline / Unit Tests (push) Successful in 8m45s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
根因:前端轮询用 GET 接口查状态,但 GET 只读 DB 不刷新,
导致任务提交后 status 一直是 submitted,前端永远等不到 completed。

修复:GET 端点对非终态任务(pending/submitted/processing)自动
调用 refresh_job_status 从 MediaKit 拉最新状态,前端轮询即触发。
2026-09-09 14:46:19 +08:00
xiaoxia 3d179528cf fix: 对口型弹窗只能点取消按钮关闭,点击空白不关闭
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Validate - Style (push) Successful in 2m26s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m16s
CI/CD Pipeline / Validate - Security (push) Successful in 6m28s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 14:39:01 +08:00
xiaoxia f33e2962e9 feat: 对口型生成弹窗(加载动画+进度+取消按钮)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m5s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Style (push) Successful in 2m32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m41s
CI/CD Pipeline / Validate - Security (push) Successful in 6m28s
CI/CD Pipeline / Unit Tests (push) Successful in 8m30s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-09 14:26:54 +08:00
xiaoxia e96cf541b1 fix: safeUrl 未使用导致 TS6133 编译失败,Docker build 连续挂
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 44s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m40s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Validate - Style (push) Successful in 2m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m45s
CI/CD Pipeline / Validate - Security (push) Successful in 5m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Unit Tests (push) Successful in 8m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-09 13:44:31 +08:00
saas-backend-agent c7d611c827 fix: CosyVoice 返回的 audio_url 从 http:// 升级为 https://
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25s
CI/CD Pipeline / Validate - Style (push) Successful in 1m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m47s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m3s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m52s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m7s
CI/CD Pipeline / Validate - Security (push) Successful in 5m39s
CI/CD Pipeline / Unit Tests (push) Successful in 7m58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
DashScope CosyVoice API 返回的音频 URL 是 http://,
前端 mixed content 策略下无法加载。
在 submit_synthesize_task 源头统一替换为 https://,
覆盖 TTS preview、对口型、TTS 异步任务全链路。
2026-09-09 13:33:16 +08:00
xiaoxia 5c8d27c3c1 debug: 对口型生成加 Console 调试日志
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m30s
CI/CD Pipeline / Integration Tests (push) Successful in 1m31s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m43s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m17s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 打印 videoId/voiceId/voiceType/textLen
- 打印 getAssetById 响应(file_url 前100字符)
- 打印 createLipsyncJob 请求 payload 和响应
- catch 打印 HTTP status + response data
- 帮助定位 422/400/500 具体原因
2026-09-09 13:30:17 +08:00
xiaoxia c4e0dcaee4 fix: add heading to Step 5 single video for E2E test (#1820)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m2s
CI/CD Pipeline / Integration Tests (push) Successful in 2m15s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m53s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 5m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m13s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Closes E2E 测试连续失败问题
2026-09-09 13:22:19 +08:00
xiaoxia 12696f35f8 fix: 克隆音色试听音频 URL HTTP→HTTPS 兼容(Mixed Content)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
后端 /tts/preview 返回 dashscope OSS HTTP URL,
staging HTTPS 页面因 Mixed Content 阻止加载。
OSS 同时支持 HTTPS,前端临时替换协议。
后端应改用 sign_url 返回 HTTPS(已通知后端修复)。
2026-09-09 13:21:53 +08:00
saas-backend-agent 6601b8facb fix: lipsync/ai-avatar 路由 current_user.id → current_user.user.id
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m3s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Validate - Style (push) Successful in 2m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m21s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Validate - Security (push) Successful in 5m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m9s
CI/CD Pipeline / Unit Tests (push) Successful in 9m17s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
根因:AuthenticatedUser 是 dataclass {user: User, session_id, token_type},
没有 .id 属性,用户 ID 在 .user.id。
staging 上所有 lipsync 端点 500(GET/POST)、ai-avatar 端点同理。
修复 11 处:lipsync.py(6) + ai_avatar_render.py(5)。
2026-09-09 13:04:34 +08:00
saas-backend-agent d6c5e66bba fix: lipsync/ai-avatar 路由 current_user.id → current_user.user.id
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
根因:AuthenticatedUser 是 dataclass {user: User, session_id, token_type},
没有 .id 属性,用户 ID 在 .user.id。
staging 上所有 lipsync 端点 500(GET/POST)、ai-avatar 端点同理。
修复 11 处:lipsync.py(6) + ai_avatar_render.py(5)。
2026-09-09 13:02:01 +08:00
saas-backend-agent c0c9765eb0 debug: lipsync 路由增加详细错误暴露,定位 staging 500 根因
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m1s
CI/CD Pipeline / Integration Tests (push) Successful in 1m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m24s
CI/CD Pipeline / Validate - Style (push) Successful in 2m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m14s
CI/CD Pipeline / Validate - Security (push) Successful in 5m9s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Unit Tests (push) Successful in 8m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
- _get_service 依赖注入加 try/except,暴露 LipsyncService 初始化错误
- list_lipsync_jobs 加 try/except,暴露 DB 查询错误
- 错误信息包含异常类型和详情,方便 staging 实测定位
2026-09-09 12:53:25 +08:00
xiaoxia ecedfc4381 fix: 克隆音色试听按钮不禁用(preview_url/audio_url 为空但可走 TTS 合成)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m8s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m47s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-09 12:48:21 +08:00
xiaoxia 5b778fbb3e debug: AI数字人克隆音色试听加 Network/Console 调试日志 (#1819)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m24s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / Validate - Style (push) Successful in 2m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m16s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Validate - Security (push) Successful in 5m18s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 12:40:17 +08:00
xiaoxia 5eb8d6b31c debug: AI数字人克隆音色试听加 Network/Console 调试日志
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 56s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m25s
CI/CD Pipeline / Validate - Style (push) Successful in 1m29s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m28s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 请求前打印 voice_id/voice_name/voice_clone_profile_id
- 响应后打印 audio_url 前80字符 + duration
- catch 打印 HTTP status + response data + message
- 帮助定位 /tts/preview 400/422/500/502 具体原因
2026-09-09 12:37:34 +08:00
xiaoxia 9121341f1b fix: AI数字人克隆音色试听传 profile UUID 而非 voice_id
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m18s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Security (push) Successful in 7m46s
CI/CD Pipeline / Unit Tests (push) Successful in 8m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
根因:后端 /tts/preview 用传入的 voice_id 去查 VoiceCloneProfile,
期望前端传 profile UUID(voice_clone_profile_id 或 clone.id)。
此前 clone 分支传了 voice_id(CosyVoice 模型 ID),
后端查不到 profile 静默返回,用户点播放无反应。

修复:clone 分支 targetId 改用 voice.voice_clone_profile_id || voice.id。
2026-09-09 11:34:36 +08:00
xiaoxia c3023cc11b fix: #1809 补充 对口型接口参数校验返回400而非500 (#1818)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m25s
CI/CD Pipeline / Integration Tests (push) Successful in 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Validate - Style (push) Successful in 2m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Validate - Security (push) Successful in 6m42s
CI/CD Pipeline / Unit Tests (push) Successful in 7m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 11:12:08 +08:00
xiaoxia 547561f473 fix: AI数字人对口型接口对齐后端新参数 video_url (#1809 补充) (#1816)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m25s
CI/CD Pipeline / Integration Tests (push) Successful in 1m55s
CI/CD Pipeline / Validate - Style (push) Successful in 2m37s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m8s
CI/CD Pipeline / Validate - Security (push) Successful in 5m59s
CI/CD Pipeline / Unit Tests (push) Successful in 7m49s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 10:35:50 +08:00
xiaoxia a10e05654c fix: AI数字人「我的音色」克隆音色试听改用 TTS 合成 (#1815)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m39s
CI/CD Pipeline / Integration Tests (push) Successful in 1m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Validate - Security (push) Successful in 5m51s
CI/CD Pipeline / Unit Tests (push) Successful in 7m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 10:22:28 +08:00
xiaoxia c954e334e6 fix: #1809 对口型接口参数调整,后端内部调TTS合成音频 (#1814)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Integration Tests (push) Successful in 2m14s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m48s
CI/CD Pipeline / Validate - Style (push) Successful in 2m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m29s
CI/CD Pipeline / Validate - Security (push) Successful in 6m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m30s
CI/CD Pipeline / Unit Tests (push) Successful in 10m41s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 10:11:27 +08:00
xiaoxia 7c738ee64d fix: AI数字人「我的音色」试听无音频时弹提示而非静默返回 (#1813)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m30s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / Validate - Style (push) Successful in 1m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Unit Tests (push) Successful in 8m56s
CI/CD Pipeline / Validate - Security (push) Successful in 9m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 09:55:10 +08:00
xiaoxia 7209b7d481 feat: #1811 新增文案库独立页面 (#1812)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Validate - Style (push) Successful in 1m21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m48s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Security (push) Successful in 4m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Unit Tests (push) Successful in 7m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 22m59s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 08:59:27 +08:00
xiaoxia 165574f826 fix: #1809 AI数字人页面 7 项实测问题修复 (#1810)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m49s
CI/CD Pipeline / Integration Tests (push) Successful in 2m14s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m26s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m8s
CI/CD Pipeline / Validate - Security (push) Successful in 6m2s
CI/CD Pipeline / Unit Tests (push) Successful in 8m35s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 01:00:17 +08:00
xiaoxia a7e6145bb1 fix: #1807 侧边栏缩窄 240px→128px + 页面内容区拓宽 (#1808)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 50s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Validate - Style (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Unit Tests (push) Successful in 7m41s
CI/CD Pipeline / Validate - Security (push) Successful in 8m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-09 00:18:00 +08:00
xiaoxia 6ee99a652c fix: 部署脚本 worker health-cmd 未转义导致 staging 部署失败回滚 (#1806)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 59s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m31s
CI/CD Pipeline / Validate - Style (push) Successful in 2m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m34s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m0s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Unit Tests (push) Successful in 6m47s
CI/CD Pipeline / Validate - Security (push) Successful in 20m59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-08 22:41:28 +08:00
xiaoxia b5bc285aff feat: #1797 AI数字人前端页面v3 — 素材库选择/配音库复用/TitleStylePanel直接import
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m35s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m49s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m4s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m35s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 23m11s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m20s
- 面板1:出镜视频改为从素材库弹窗选择(ModalAssetPicker),竖屏9:16预览
- 面板2:配音库改为系统预设/我的音色双tab切换,复用 /api/v1/voices
- 面板3:对口型三态(生成/进度/完成+重新生成),竖屏9:16预览
- 面板4:标题配置直接 import TitleStylePanel + TITLE_PRESETS,与智能剪辑完全一致
- 面板5:封面+分辨率+配置汇总+渐变生成按钮,删除多余画面插入模式
- B-roll弹窗:已选素材标灰pointer-events:none防重选,画中画四角位置
- 路由 /app/ai-avatar + 侧边栏AI数字人导航项
2026-09-08 21:21:27 +08:00
80 changed files with 4004 additions and 2706 deletions
+16 -2
View File
@@ -20,6 +20,7 @@ on:
default: "手动触发 - CI漏触发补跑"
permissions:
contents: read
pull-requests: read
concurrency:
group: ci-pipeline-${{ gitea.ref }}
cancel-in-progress: true
@@ -88,9 +89,22 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
# 优先用 git diff 判断 PR 改动范围(比 API 稳定)
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
if command -v git >/dev/null 2>&1 && [ -d .git ]; then
FILES=$(git diff --name-only origin/develop...HEAD 2>/dev/null || true)
fi
if [ -z "${FILES:-}" ]; then
# fallback 到 API
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]" 2>/dev/null || true)
fi
if [ -z "${FILES:-}" ]; then
echo "⚠️ 无法获取变更文件列表,保守运行完整 CI"
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
exit 0
fi
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
@@ -0,0 +1,45 @@
"""lipsync_jobs 增加 TTS 直生字段(voice_id/script_text/speed/emotion
Revision ID: 073_add_lipsync_tts_fields
Revises: 072_add_ai_avatar_render
Create Date: 2026-09-09
"""
import sqlalchemy as sa
from alembic import op
revision = "073_add_lipsync_tts_fields"
down_revision = "072_add_ai_avatar_render"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 对口型支持「传音色 + 文案直接生成」:后端内部先 TTS 合成音频再提交对口型
op.add_column(
"lipsync_jobs",
sa.Column("voice_id", sa.String(200), nullable=False, server_default=""),
)
op.add_column(
"lipsync_jobs",
sa.Column("script_text", sa.Text(), nullable=False, server_default=""),
)
op.add_column(
"lipsync_jobs",
sa.Column("speed", sa.Float(), nullable=False, server_default=sa.text("1.0")),
)
op.add_column(
"lipsync_jobs",
sa.Column("emotion", sa.String(20), nullable=False, server_default=""),
)
# audio_url 改为可空:直生模式下音频由后端 TTS 合成后回填
op.alter_column("lipsync_jobs", "audio_url", existing_type=sa.Text(), nullable=True)
def downgrade() -> None:
op.alter_column("lipsync_jobs", "audio_url", existing_type=sa.Text(), nullable=False)
op.drop_column("lipsync_jobs", "emotion")
op.drop_column("lipsync_jobs", "speed")
op.drop_column("lipsync_jobs", "script_text")
op.drop_column("lipsync_jobs", "voice_id")
@@ -0,0 +1,36 @@
"""ai_avatar_render_jobs.script_id 放宽为可空串(手动文案直生场景不关联文案库)
Revision ID: 074_render_script_id_optional
Revises: 073_add_lipsync_tts_fields
Create Date: 2026-09-09
"""
import sqlalchemy as sa
from alembic import op
revision = "074_render_script_id_optional"
down_revision = "073_add_lipsync_tts_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 列保持 NOT NULL(空串占位),仅应用层允许不传;这里显式补 server_default 防止历史约束歧义
with op.batch_alter_table("ai_avatar_render_jobs") as batch:
batch.alter_column(
"script_id",
existing_type=sa.String(length=36),
nullable=False,
server_default="",
)
def downgrade() -> None:
with op.batch_alter_table("ai_avatar_render_jobs") as batch:
batch.alter_column(
"script_id",
existing_type=sa.String(length=36),
nullable=False,
server_default=None,
)
+48 -5
View File
@@ -17,7 +17,10 @@ from app.dependencies import get_db_session
from app.schemas.ai_avatar_render import (
AiAvatarRenderJobResponse,
CreateAiAvatarRenderRequest,
SmartCoverRequest,
SmartCoverResponse,
)
from app.services.ai_avatar_cover_service import generate_smart_cover
from app.services.ai_avatar_render_service import (
AiAvatarRenderError,
AiAvatarRenderService,
@@ -49,7 +52,7 @@ def create_render_job(
"""
try:
job = svc.create_render_job(
user_id=current_user.id,
user_id=current_user.user.id,
lipsync_job_id=body.lipsync_job_id,
script_id=body.script_id,
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
@@ -94,7 +97,7 @@ def list_render_jobs(
):
"""获取 AI 数字人渲染任务列表."""
items, total = svc.list_render_jobs(
user_id=current_user.id,
user_id=current_user.user.id,
project_id=project_id,
status=status,
offset=offset,
@@ -118,7 +121,7 @@ def get_render_job(
svc: AiAvatarRenderService = Depends(_get_service),
):
"""获取渲染任务详情."""
job = svc.get_render_job(job_id, current_user.id)
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
return job
@@ -134,7 +137,7 @@ def cancel_render_job(
svc: AiAvatarRenderService = Depends(_get_service),
):
"""取消渲染任务(仅 pending 状态可取消)."""
job = svc.cancel_render_job(job_id, current_user.id)
job = svc.cancel_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "cancelled":
@@ -155,7 +158,7 @@ def retry_render_job(
svc: AiAvatarRenderService = Depends(_get_service),
):
"""重试失败的渲染任务."""
job = svc.retry_render_job(job_id, current_user.id)
job = svc.retry_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "pending":
@@ -173,3 +176,43 @@ def retry_render_job(
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
return job
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
@router.post("/smart-cover", response_model=SmartCoverResponse)
def generate_avatar_smart_cover(
body: SmartCoverRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
) -> SmartCoverResponse:
"""智能获取数字人视频封面.
复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧逻辑(非 FFmpeg 简单截帧),
并将选中帧转存到自家 OSS,返回非临时的封面公网 URL。
前端「智能获取封面」按钮可直接调用本接口;不依赖渲染任务完成。
"""
video_url = (body.video_url or "").strip()
if not video_url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL")
try:
cover_url = generate_smart_cover(video_url, max_frames=body.max_frames)
except Exception as exc:
logger.error(
"智能封面生成异常: user=%s video_url=%s err=%s",
current_user.user.id, video_url[:80], exc,
exc_info=True,
)
cover_url = ""
if not cover_url:
return SmartCoverResponse(
cover_url="",
status="fallback_failed",
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
)
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
return SmartCoverResponse(cover_url=cover_url, status="completed")
+69 -15
View File
@@ -1,4 +1,4 @@
"""对口型 API 路由 — #1796 MediaKit 对口型.
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整.
接口:
POST /api/v1/lipsync/jobs 提交对口型任务
@@ -13,20 +13,36 @@ from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.dependencies import (
get_cosyvoice_service,
get_db_session,
get_voice_clone_profile_repository,
)
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_service(db: Session = Depends(get_db_session)) -> LipsyncService:
return LipsyncService(db)
def _get_service(
db: Session = Depends(get_db_session),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
) -> LipsyncService:
# voice_clone_repo 用于克隆音色 profile 解析;cosyvoice_service 用于 TTS 直生
# (TTS 合成、音色解析、错误码归一化都在 LipsyncService 内部完成)
return LipsyncService(
db,
cosyvoice_service=cosyvoice_service,
voice_clone_repo=voice_clone_repo,
)
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
@@ -40,26 +56,55 @@ def create_lipsync_job(
):
"""提交对口型任务.
输入人物视频 + 驱动音频,异步生成口型对齐视频。
#1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?}
后端内部解析音色、调 TTS 合成音频、转存 OSS,再提交 MediaKit
也支持直接传 {video_url, audio_url}。
"""
try:
job = svc.create_job(
user_id=current_user.id,
user_id=current_user.user.id,
video_url=body.video_url,
audio_url=body.audio_url,
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
emotion=body.emotion,
enable_video_loop=body.enable_video_loop,
project_id=body.project_id,
)
except MediaKitError as exc:
# 创建失败(job 已记录 error),返回 502
except ValueError as exc:
# 参数无效(如 voice_id 格式不对、文本过长等)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except CosyVoiceError as exc:
# TTS 合成基础设施失败(API/网络/认证)
raise HTTPException(
status_code=502,
detail={"code": "TTSSynthesisFailed", "message": str(exc)},
) from exc
except MediaKitError as exc:
# TTS 合成失败 / 音色无权访问 → 400/403MediaKit 提交失败 → 502
status_code = 502
if exc.code in ("VoiceForbidden",):
status_code = 403
elif exc.code in ("InvalidInput", "TTSInvalidParam", "VoiceNotReady"):
status_code = 400
elif exc.code == "TTSSynthesisFailed":
status_code = 502
raise HTTPException(
status_code=status_code,
detail={
"code": exc.code,
"message": str(exc),
"request_id": exc.request_id,
"request_id": getattr(exc, "request_id", ""),
},
) from exc
except Exception as exc:
# 兜底:任何未预期的错误返回 400 而非 500
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
raise HTTPException(
status_code=400,
detail=f"创建对口型任务失败: {exc}",
) from exc
return job
@@ -78,7 +123,7 @@ def list_lipsync_jobs(
):
"""获取对口型任务列表."""
items, total = svc.list_jobs(
user_id=current_user.id,
user_id=current_user.user.id,
project_id=project_id,
status=status,
offset=offset,
@@ -98,13 +143,22 @@ def list_lipsync_jobs(
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
def get_lipsync_job(
job_id: str,
background: BackgroundTasks,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""获取对口型任务详情."""
job = svc.get_job(job_id, current_user.id)
"""获取对口型任务详情.
非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态),
避免 MediaKit 慢响应阻塞前端轮询。
"""
job = svc.get_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.status not in ("completed", "failed"):
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
return job
@@ -118,7 +172,7 @@ def refresh_lipsync_job(
svc: LipsyncService = Depends(_get_service),
):
"""从 MediaKit 拉取最新状态并更新."""
job = svc.refresh_job_status(job_id, current_user.id)
job = svc.refresh_job_status(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
return job
@@ -134,7 +188,7 @@ def cancel_lipsync_job(
svc: LipsyncService = Depends(_get_service),
):
"""取消对口型任务(仅 pending/submitted 状态可取消)."""
job = svc.cancel_job(job_id, current_user.id)
job = svc.cancel_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.status != "cancelled":
+10 -1
View File
@@ -173,6 +173,14 @@ def synthesize(
# job.voice_id 统一存解析后的 CosyVoice voice_id
actual_voice_id = resolved_profile.voice_id
# 语速/情绪等合成参数随 metadata 落库,workflow 提交 CosyVoice 时读取透传
synthesis_meta = {
"speed": request.speed,
"emotion": request.emotion or "",
}
if request.metadata_:
synthesis_meta.update(request.metadata_)
use_case = CreateTTSJobUseCase(repository)
job = use_case.execute(
user_id=user_id,
@@ -180,7 +188,7 @@ def synthesize(
voice_id=actual_voice_id,
voice_model=request.voice_model,
voice_clone_profile_id=voice_clone_profile_id,
metadata=request.metadata_,
metadata=synthesis_meta,
)
# 提交 CosyVoice 合成任务
@@ -567,6 +575,7 @@ def preview_tts(
text=request.text,
voice_id=actual_voice_id,
speed=request.speed,
emotion=request.emotion,
)
except CosyVoiceError as e:
raise HTTPException(
+19 -6
View File
@@ -50,7 +50,7 @@ class CreateAiAvatarRenderRequest(BaseModel):
"""创建渲染任务请求."""
lipsync_job_id: str = Field(..., description="对口型任务 ID")
script_id: str = Field(..., description="文案 ID")
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
title_config: dict[str, Any] = Field(default_factory=dict, description="标题配置")
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
@@ -67,10 +67,8 @@ class CreateAiAvatarRenderRequest(BaseModel):
@field_validator("script_id")
@classmethod
def validate_script_id(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("script_id 不能为空")
return v
# script_id 可选:手动输入文案(TTS 直生)场景不关联文案库条目
return (v or "").strip()
class AiAvatarRenderJobResponse(BaseModel):
@@ -80,7 +78,7 @@ class AiAvatarRenderJobResponse(BaseModel):
user_id: str
project_id: str
lipsync_job_id: str
script_id: str
script_id: str = ""
b_roll_segments: list[dict[str, Any]]
title_config: dict[str, Any]
cover_config: dict[str, Any]
@@ -109,3 +107,18 @@ class AiAvatarRenderProgressResponse(BaseModel):
output_cover_url: str
output_duration: float
error_message: str
class SmartCoverRequest(BaseModel):
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧."""
video_url: str = Field(..., description="数字人视频 URL(对口型/渲染成片)")
max_frames: int = Field(5, ge=1, le=10, description="抽帧数量(默认 5")
class SmartCoverResponse(BaseModel):
"""智能封面响应."""
cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空")
status: str = Field("completed", description="completed / fallback_failed")
message: str = Field("", description="失败原因(如有)")
+57 -27
View File
@@ -1,11 +1,17 @@
"""对口型 API Schema 定义 — #1796."""
"""对口型 API Schema 定义 — #1796 / #1809 / #1822.
支持两种输入模式(二选一):
1. TTS 直生模式(推荐):传 voice_id + script_text+ speed/emotion),
后端内部先调 CosyVoice 合成音频,再提交 MediaKit 对口型。
2. 直接音频模式:传 video_url + audio_url(音频已由调用方准备好)。
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, model_validator
class LipsyncJobResponse(BaseModel):
@@ -17,6 +23,10 @@ class LipsyncJobResponse(BaseModel):
video_url: str
audio_url: str
enable_video_loop: bool
voice_id: str = ""
script_text: str = ""
speed: float = 1.0
emotion: str = ""
mediakit_task_id: str
status: str
output_video_url: str
@@ -33,38 +43,58 @@ class LipsyncJobResponse(BaseModel):
class CreateLipsyncJobRequest(BaseModel):
"""创建对口型任务请求."""
"""创建对口型任务请求.
两种模式(二选一):
- TTS 直生:voice_id + script_text 必填(+ 可选 speed/emotion);audio_url 留空。
- 直接音频:video_url + audio_url 必填。
"""
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
audio_url: str = Field(..., description="驱动音频 URLmp3/aac/wav/m4a/flac")
# 模式 2:直接音频
audio_url: str = Field("", description="驱动音频 URLmp3/aac/wav/m4a/flac);直生模式留空")
# 模式 1TTS 直生
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID")
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
project_id: str = Field("", description="项目 ID(可选)")
@field_validator("video_url")
@classmethod
def validate_video_url(cls, v: str) -> str:
v = v.strip()
if not v:
@model_validator(mode="after")
def _validate_input_mode(self) -> "CreateLipsyncJobRequest":
video = (self.video_url or "").strip()
if not video:
raise ValueError("video_url 不能为空")
if not v.startswith(("http://", "https://")):
if not video.startswith(("http://", "https://")):
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
# 仅支持 MP4
lower = v.lower().split("?")[0]
lower = video.lower().split("?")[0]
if not lower.endswith(".mp4"):
raise ValueError("video_url 仅支持 MP4 格式")
return v
@field_validator("audio_url")
@classmethod
def validate_audio_url(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("audio_url 不能为空")
if not v.startswith(("http://", "https://")):
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
# 支持的音频格式
lower = v.lower().split("?")[0]
allowed_exts = (".mp3", ".aac", ".wav", ".m4a", ".flac")
if not any(lower.endswith(ext) for ext in allowed_exts):
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed_exts)}")
return v
has_audio = bool((self.audio_url or "").strip())
has_tts = bool((self.voice_id or "").strip()) and bool((self.script_text or "").strip())
if not has_audio and not has_tts:
raise ValueError(
"必须提供驱动音频:要么传 audio_url(直接音频模式),"
"要么同时传 voice_id + script_textTTS 直生模式)"
)
if has_tts and len(self.script_text) > 5000:
raise ValueError("script_text 最长 5000 字符")
if has_audio:
au = self.audio_url.strip()
if not au.startswith(("http://", "https://")):
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
au_lower = au.lower().split("?")[0]
allowed = (".mp3", ".aac", ".wav", ".m4a", ".flac")
if not any(au_lower.endswith(ext) for ext in allowed):
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed)}")
self.audio_url = au
return self
+2
View File
@@ -16,6 +16,7 @@ class TTSSynthesizeRequest(BaseModel):
output_name: str = Field("", description="输出文件名")
language: str = Field("zh-CN", description="语言")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文 自然/兴奋/沉稳/亲切)")
voice_model: str = Field("", description="语音模型名称")
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
format: str = Field("mp3", description="输出格式(mp3/wav/pcm")
@@ -109,6 +110,7 @@ class TTSPreviewRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
voice_id: str = Field(..., min_length=1, description="音色 ID")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文)")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
@@ -0,0 +1,215 @@
"""AI 数字人封面服务 — 复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧.
与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧):
1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略)
2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳
3. 下载最佳帧并转存 OSS,返回公网封面 URL
降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。
"""
from __future__ import annotations
import logging
import tempfile
import uuid
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# MediaKit 抽帧轮询参数(与 MediaKit API timeout=60s 对齐)
COVER_POLL_INTERVAL = 3.0
COVER_MAX_POLL_ATTEMPTS = 20 # 最多等 60 秒
# 帧图片下载超时(秒)
FRAME_DOWNLOAD_TIMEOUT = 20
# 最佳帧下载超时(用于 persist)
BEST_FRAME_DOWNLOAD_TIMEOUT = 30
def _sign_video_url_for_mediakit(video_url: str) -> str:
"""如果 video_url 是自家 OSS 私有桶 URL,重新签名为长有效期预签名 URL。
MediaKit GPU worker 需要能公网访问 video_url,裸 public_url 在私有桶下会 403。
"""
if not video_url:
return video_url
try:
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return video_url
own_host = urlparse(public_base).netloc.lower()
url_host = urlparse(video_url).netloc.lower()
if own_host and url_host == own_host:
# 是自家 OSS URL,重签 7 天有效期供 MediaKit 拉取
signed = storage.get_download_url(video_url, expires_seconds=7 * 24 * 3600)
if signed:
logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)")
return signed
except Exception:
logger.warning("[数字人封面] video_url 重签失败,使用原始 URL", exc_info=True)
return video_url
def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL.
Args:
video_url: 可公网访问的视频 URL
max_frames: 抽帧数量
Returns:
最佳帧图片 URL;失败返回空字符串
"""
if not video_url:
return ""
# 确保 MediaKit 能访问 video_url(自家 OSS 私有桶需重签)
video_url = _sign_video_url_for_mediakit(video_url)
try:
from packages.shared.cover_frame_scorer import score_frames
from packages.shared.mediakit_client import get_mediakit_client
mk = get_mediakit_client()
if not mk.is_available:
logger.warning("[数字人封面] MediaKit 未配置,无法智能抽帧")
return ""
logger.info(
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d poll_interval=%.1f max_poll=%d",
video_url[:80],
max_frames,
COVER_POLL_INTERVAL,
COVER_MAX_POLL_ATTEMPTS,
)
snapshots = mk.extract_frames(
video_url=video_url,
strategy="SpecifiedFrames",
max_frames=max_frames,
poll_interval=COVER_POLL_INTERVAL,
max_poll_attempts=COVER_MAX_POLL_ATTEMPTS,
max_retries=1,
)
if not snapshots:
logger.warning("[数字人封面] MediaKit 未返回帧: %s", video_url[:80])
return ""
if len(snapshots) == 1:
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
# 使用连接池下载各帧(复用 TCP 连接,减少延迟)
import httpx
candidates = []
with httpx.Client(timeout=FRAME_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
for snap in snapshots:
url = snap.get("image_url") or snap.get("url") or ""
if not url:
continue
tmp_path: Optional[str] = None
try:
resp = client.get(url)
resp.raise_for_status()
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(resp.content)
tmp_path = tmp.name
candidates.append({"image_path": tmp_path, "url": url})
except Exception as e:
logger.warning("[数字人封面] 帧下载失败,跳过: url=%s err=%s", url[:80], e)
candidates.append({"image_path": None, "url": url, "score": 0.0})
if not candidates:
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
scored = score_frames(candidates)
best = scored[0] if scored else None
best_url = best.get("url", "") if best else ""
# 清理临时文件
for c in candidates:
p = c.get("image_path")
if p:
try:
Path(p).unlink(missing_ok=True)
except Exception:
pass
logger.info(
"[数字人封面] 智能选帧完成: candidates=%d best_score=%s",
len(candidates),
best.get("score") if best else "n/a",
)
return best_url
except Exception:
logger.warning("[数字人封面] 智能选帧失败", exc_info=True)
return ""
def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-avatar/covers") -> str:
"""下载帧图并转存到 OSS,返回公网封面 URL.
Args:
frame_url: MediaKit 返回的临时帧图 URL
job_id: 关联任务 ID(用于 OSS key 命名)
prefix: OSS key 前缀
Returns:
OSS 公网 URL;失败回退原始 frame_url
"""
if not frame_url:
return ""
tmp_path: Optional[str] = None
try:
import httpx
with httpx.Client(timeout=BEST_FRAME_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
resp = client.get(frame_url)
resp.raise_for_status()
if not resp.content:
logger.warning("[数字人封面] 帧图内容为空: %s", frame_url[:80])
return frame_url
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(resp.content)
tmp_path = tmp.name
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
token = job_id or uuid.uuid4().hex[:12]
cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg"
public_url = storage.upload_file(
file_or_path=tmp_path,
storage_key=cover_key,
content_type="image/jpeg",
)
logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key)
return public_url or frame_url
except Exception:
logger.warning("[数字人封面] 封面转存 OSS 失败,返回原始 URL", exc_info=True)
return frame_url
finally:
if tmp_path:
try:
Path(tmp_path).unlink(missing_ok=True)
except Exception:
pass
def generate_smart_cover(video_url: str, *, job_id: str = "", max_frames: int = 5) -> str:
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS,返回封面公网 URL.
供独立封面接口与渲染管线复用。失败返回空字符串。
"""
best_frame = select_best_cover_frame(video_url, max_frames=max_frames)
if not best_frame:
return ""
return persist_cover_to_oss(best_frame, job_id=job_id)
@@ -53,8 +53,8 @@ class AiAvatarRenderService:
*,
user_id: str,
lipsync_job_id: str,
script_id: str,
b_roll_segments: list[dict[str, Any]],
script_id: str = "",
b_roll_segments: list[dict[str, Any]] | None = None,
title_config: dict[str, Any],
cover_config: dict[str, Any],
project_id: str = "",
@@ -83,17 +83,19 @@ class AiAvatarRenderService:
if not lipsync_job.output_video_url:
raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput")
# 2. 验证文案归属
script = (
self.db.query(ScriptModel)
.filter(
ScriptModel.id == script_id,
ScriptModel.user_id == user_id,
# 2. 验证文案归属(仅当选了文案库条目时;手动输入文案直生场景 script_id 可空)
script_id = (script_id or "").strip()
if script_id:
script = (
self.db.query(ScriptModel)
.filter(
ScriptModel.id == script_id,
ScriptModel.user_id == user_id,
)
.first()
)
.first()
)
if script is None:
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
if script is None:
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
# 3. 创建渲染任务
job_id = str(uuid.uuid4())
@@ -103,7 +105,7 @@ class AiAvatarRenderService:
project_id=project_id,
lipsync_job_id=lipsync_job_id,
script_id=script_id,
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in b_roll_segments],
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])],
title_config=title_config,
cover_config=cover_config,
status="pending",
@@ -288,7 +290,22 @@ class AiAvatarRenderService:
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
job.output_video_url = output_video_url
if cover_path:
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧;
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
smart_cover_url = ""
if output_video_url:
try:
from app.services.ai_avatar_cover_service import (
generate_smart_cover,
)
smart_cover_url = generate_smart_cover(output_video_url, job_id=job_id, max_frames=5)
except Exception:
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
if smart_cover_url:
job.output_cover_url = smart_cover_url
elif cover_path:
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
job.output_cover_url = output_cover_url
+166 -9
View File
@@ -1,14 +1,16 @@
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
职责:
- 创建/查询/取消对口型任务
- 创建/查询对口型任务
- 双输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url
- 调用 MediaKit 客户端提交异步任务
- 轮询更新任务状态
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
- 用户隔离(每个用户只能操作自己的任务)
"""
from __future__ import annotations
import io
import logging
import uuid
from datetime import datetime, timezone
@@ -25,6 +27,9 @@ from app.services.mediakit_client import (
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError, normalize_emotion
from packages.shared.storage import get_shared_storage_service
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
logger = logging.getLogger(__name__)
@@ -32,9 +37,102 @@ logger = logging.getLogger(__name__)
class LipsyncService:
"""对口型任务 Service."""
def __init__(self, db: Session, client: Optional[MediaKitClient] = None):
def __init__(
self,
db: Session,
client: Optional[MediaKitClient] = None,
cosyvoice_service=None,
voice_clone_repo=None,
):
self.db = db
self.client = client or get_mediakit_client()
self._cosyvoice = cosyvoice_service
self._voice_clone_repo = voice_clone_repo
def _get_cosyvoice(self):
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
if self._cosyvoice is None:
from app.dependencies import get_cosyvoice_service
self._cosyvoice = get_cosyvoice_service()
return self._cosyvoice
def _resolve_voice_id(self, voice_id: str, user_id: str) -> str:
"""将克隆音色 profile UUID 解析为 CosyVoice voice_id。
与 /tts/synthesize 保持一致:命中 profile → 校验归属 → 返回其 voice_id;
未命中(预置音色 ID 或克隆 CosyVoice voice_id)原样返回。
"""
if not voice_id:
return ""
if self._voice_clone_repo is None:
try:
from app.dependencies import get_voice_clone_profile_repository
self._voice_clone_repo = get_voice_clone_profile_repository(self.db)
except Exception:
return voice_id
try:
profile = self._voice_clone_repo.get(voice_id)
except Exception:
return voice_id
if profile is None:
return voice_id
if getattr(profile, "user_id", "") != user_id:
raise MediaKitError("无权访问该音色", code="VoiceForbidden")
if not getattr(profile, "voice_id", ""):
raise MediaKitError("音色克隆尚未完成,请稍后再试", code="VoiceNotReady")
return profile.voice_id
def _synthesize_and_persist_audio(
self,
*,
user_id: str,
job_id: str,
voice_id: str,
script_text: str,
speed: float,
emotion: str,
) -> str:
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
Raises:
MediaKitError: 合成失败
"""
actual_voice_id = self._resolve_voice_id(voice_id, user_id)
cosyvoice = self._get_cosyvoice()
try:
result = cosyvoice.submit_synthesize_task(
text=script_text,
voice_id=actual_voice_id,
speed=speed,
emotion=normalize_emotion(emotion),
)
except CosyVoiceError as exc:
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
except ValueError as exc:
raise MediaKitError(f"TTS 参数错误: {exc}", code="TTSInvalidParam") from exc
temp_url = result.get("audio_url", "")
if not temp_url:
raise MediaKitError("TTS 未返回音频 URL", code="TTSNoAudio")
# 转存到自家 OSS,避免临时 URL 过期导致 MediaKit 拉取失败
try:
audio_data = safe_download_bytes(
temp_url,
purpose="lipsync_tts_audio",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
storage = get_shared_storage_service()
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info("对口型 TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
return permanent_url
except Exception as exc:
logger.warning("TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
return temp_url
# ── 创建任务 ──────────────────────────────────────────────────────────
@@ -43,15 +141,41 @@ class LipsyncService:
*,
user_id: str,
video_url: str,
audio_url: str,
audio_url: str = "",
voice_id: str = "",
script_text: str = "",
speed: float = 1.0,
emotion: str = "",
enable_video_loop: bool = False,
project_id: str = "",
) -> LipsyncJobModel:
"""创建对口型任务并提交到 MediaKit.
两种输入模式:
- TTS 直生:voice_id + script_textaudio_url 留空),后端先合成音频
- 直接音频:提供 audio_url
Raises:
MediaKitError: API 调用失败
MediaKitError: TTS 合成或 MediaKit 提交失败
"""
# 0. TTS 直生模式:先合成音频(在创建 DB 记录之前完成,失败直接抛出)
if not audio_url:
if not (voice_id and script_text):
raise MediaKitError(
"必须提供 audio_url 或 voice_id+script_text",
code="InvalidInput",
)
# 预合成:用临时 job_id 命名 OSS 对象
pre_job_id = str(uuid.uuid4())
audio_url = self._synthesize_and_persist_audio(
user_id=user_id,
job_id=pre_job_id,
voice_id=voice_id,
script_text=script_text,
speed=speed,
emotion=emotion,
)
# 1. 创建数据库记录
job_id = str(uuid.uuid4())
job = LipsyncJobModel(
@@ -61,12 +185,16 @@ class LipsyncService:
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
voice_id=voice_id or "",
script_text=script_text or "",
speed=speed,
emotion=normalize_emotion(emotion),
status="pending",
)
self.db.add(job)
self.db.flush()
# 2. 提交到 MediaKit
# 3. 提交到 MediaKit
try:
result = self.client.submit_lipsync(
video_url=video_url,
@@ -145,11 +273,14 @@ class LipsyncService:
return job
mk_status = status_data.get("status", STATUS_RUNNING)
logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status)
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
job.output_video_url = result.get("video_url", "")
output_url = result.get("video_url", "")
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL)
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
elif mk_status == STATUS_FAILED:
@@ -158,12 +289,38 @@ class LipsyncService:
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(timezone.utc)
# running 状态只更新时间戳
else:
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
if isinstance(mk_status, str) and mk_status:
job.status = mk_status
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
def _persist_output_video(self, temp_url: str, job_id: str, user_id: str) -> str:
"""将 MediaKit 输出的临时视频 URL 转存到自家 OSS.
失败时回退返回原始临时 URL,不影响任务完成。
"""
if not temp_url:
return ""
try:
import httpx
with httpx.Client(timeout=180.0, follow_redirects=True) as client:
resp = client.get(temp_url)
resp.raise_for_status()
data = resp.content
storage = get_shared_storage_service()
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
logger.info("对口型输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
return permanent_url or temp_url
except Exception as exc:
logger.warning("对口型输出视频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
return temp_url
# ── 取消任务 ──────────────────────────────────────────────────────────
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
+2
View File
@@ -0,0 +1,2 @@
export * from "./scripts"
export * from "./types"
+37
View File
@@ -0,0 +1,37 @@
/**
* 文案库 API
* 对接后端 /api/v1/scriptsCRUD + 列表解包)
*/
import apiClient from "../client"
import type {
ScriptItem,
ScriptListResponse,
CreateScriptRequest,
UpdateScriptRequest,
} from "./types"
/** 获取文案列表 — 必须解包 items(后端返回 {items,total}*/
export const getScripts = async (): Promise<ScriptItem[]> => {
const response = await apiClient.get<ScriptListResponse | ScriptItem[]>("/scripts")
const data = response.data as unknown
if (Array.isArray(data)) return data
const items = (data as { items?: ScriptItem[] })?.items
return Array.isArray(items) ? items : []
}
/** 新建文案 */
export const createScript = async (data: CreateScriptRequest): Promise<ScriptItem> => {
const response = await apiClient.post<ScriptItem>("/scripts", data)
return response.data
}
/** 更新文案 */
export const updateScript = async (id: string, data: UpdateScriptRequest): Promise<ScriptItem> => {
const response = await apiClient.put<ScriptItem>(`/scripts/${id}`, data)
return response.data
}
/** 删除文案 */
export const deleteScript = async (id: string): Promise<void> => {
await apiClient.delete(`/scripts/${id}`)
}
+24
View File
@@ -0,0 +1,24 @@
/**
* 文案库 API — 类型定义
* 对接后端 /api/v1/scripts
*/
export interface ScriptItem {
id: string
title: string
content: string
char_count: number
created_at: string
updated_at?: string
}
export interface ScriptListResponse {
items: ScriptItem[]
total: number
}
export interface CreateScriptRequest {
title: string
content: string
}
export type UpdateScriptRequest = Partial<CreateScriptRequest>
+1
View File
@@ -103,6 +103,7 @@ export interface TTSPreviewRequest {
voice_id: string
speed?: number
pitch?: number
emotion?: string // 情绪参数:natural/excited/calm/friendly
}
/** TTS 试听响应 */
+10 -4
View File
@@ -5,7 +5,7 @@
.xx-app-shell 全屏 flex 容器
├── header (xx-top-nav) 顶部导航(Header.tsx 管理)
└── .xx-app-body 水平 flex 行
├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠)
├── .xx-app-sidebar 左侧侧边栏(128px / 64px 折叠)
└── .xx-app-content 主内容区(自适应)
所有尺寸/颜色均使用 global.css 设计系统变量
@@ -31,7 +31,7 @@
/* ── 侧边栏 ───────────────────────────────────────────────── */
.xx-app-sidebar {
width: 240px;
width: 128px;
flex-shrink: 0;
position: sticky;
top: 0;
@@ -103,6 +103,12 @@
padding: var(--space-sm);
}
/* 展开态(侧边栏 128px)水平 padding 收窄,为菜单文字留出完整一行空间 */
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-content {
padding-left: var(--space-xs);
padding-right: var(--space-xs);
}
/* ── 主内容区 ─────────────────────────────────────────────── */
.xx-app-content {
flex: 1;
@@ -136,7 +142,7 @@
/* 展开态恢复完整宽度 */
.xx-app-sidebar:not(.xx-collapsed) {
width: 240px;
width: 128px;
}
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle {
@@ -159,7 +165,7 @@
top: 56px; /* 移动端 Header 高度 */
left: 0;
bottom: 0;
width: 240px;
width: 128px;
transform: translateX(-100%);
transition: transform var(--transition-slow);
box-shadow: none;
@@ -2,7 +2,7 @@
* MainLayout - 主布局组件(Task 1.2
*
* 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区
* - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏
* - 侧边栏:128px 固定宽度,可折叠至 64px 图标栏
* - 顶部导航:复用 Header 组件(68px 固定高度)
* - 主内容区:自适应填充剩余空间
* - 响应式:移动端(<768px)隐藏侧边栏
+20 -10
View File
@@ -30,7 +30,7 @@
/* 分组标题 */
.xx-sidebar-group-title {
padding: var(--space-sm) var(--space-md) var(--space-xs);
padding: var(--space-sm) var(--space-sm) var(--space-xs);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--text-tertiary);
@@ -56,9 +56,9 @@
.xx-sidebar-menu-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
margin: 0 var(--space-xs);
gap: var(--space-xs);
padding: var(--space-sm) var(--space-xs);
margin: 0 var(--space-xxs);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
@@ -97,12 +97,12 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 10px;
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
color: var(--text-secondary);
font-size: 18px;
font-size: 16px;
line-height: 1;
transition: 0.15s ease;
}
@@ -119,7 +119,9 @@
/* ── 菜单项文字 ───────────────────────────────────────────── */
.xx-sidebar-menu-label {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@@ -133,8 +135,16 @@
/* 折叠时菜单项居中,仅图标 */
.xx-sidebar-nav--collapsed .xx-sidebar-menu-item {
justify-content: center;
padding: var(--space-sm);
margin: 0 var(--space-xxs);
padding: var(--space-xs);
margin: 0;
}
/* 折叠态图标恢复更大尺寸居中 */
.xx-sidebar-nav--collapsed .xx-sidebar-menu-icon {
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 16px;
}
/* 折叠时隐藏分组标题 */
+12
View File
@@ -58,6 +58,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/titles",
icon: React.createElement(FileTextOutlined),
},
{
key: "scripts",
label: "文案库",
path: "/app/scripts",
icon: React.createElement(EditOutlined),
},
{
key: "voices",
label: "配音库",
@@ -173,6 +179,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/titles",
icon: React.createElement(FileTextOutlined),
},
{
key: "scripts",
label: "文案库",
path: "/app/scripts",
icon: React.createElement(EditOutlined),
},
{
key: "products",
label: "成品库",
+1 -1
View File
@@ -11,7 +11,7 @@
.admin-coming-soon-page {
padding: 32px;
max-width: 1400px;
max-width: 1680px;
margin: 0 auto;
}
File diff suppressed because it is too large Load Diff
+412 -118
View File
@@ -1,35 +1,57 @@
/**
* AI数字人 — 主页面(v3
* 5列水平面板布局
* AI数字人 — 主页面(两步骤版
* 步骤1:出镜视频 / 配音库 / 文案
* 步骤2:插入画面 / 对口型预览 / 标题配置 / 封面&生成
*/
import React, { useState, useCallback, useEffect, useRef } from "react"
import { message } from "antd"
import "./AiAvatar.css"
import { useAiAvatar } from "./hooks/useAiAvatar"
import { PanelVideoSelector } from "./components/PanelVideoSelector"
import PanelVoiceSelector from "./components/PanelVoiceSelector"
import PanelScriptAndLipsync from "./components/PanelScriptAndLipsync"
import PanelScript from "./components/PanelScript"
import PanelLipsyncPreview from "./components/PanelLipsyncPreview"
import PanelTitleConfig from "./components/PanelTitleConfig"
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
import { ModalAssetPicker } from "./components/ModalAssetPicker"
import ModalBRollEditor from "./components/ModalBRollEditor"
import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar"
import { getAssetsByKind } from "@/api/assets"
import {
getScripts,
getAssetById,
createLipsyncJob,
getLipsyncJob,
submitRender,
generateSmartCover,
} from "./api/aiAvatar"
import {
normalizeEmotion,
buildTitleConfigPayload,
buildCoverConfigPayload,
} from "./utils/contract"
/** 面板折叠状态 */
type PanelKey = "video" | "voice" | "script" | "title" | "cover"
type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover"
const AiAvatarPage: React.FC = () => {
const state = useAiAvatar()
const [currentStep, setCurrentStep] = useState(1)
const [collapsed, setCollapsed] = useState<Record<PanelKey, boolean>>({
video: false,
voice: false,
script: false,
lipsync: false,
title: false,
cover: false,
})
/* ── 素材库弹窗 ── */
const [bRollAssets, setBRollAssets] = useState<import("@/api/assets").AssetItem[]>([])
/* ── 对口型生成弹窗 ── */
const [showLipsyncModal, setShowLipsyncModal] = useState(false)
const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">(
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ─ 智能封面加载态 ── */
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/* ── 对口型轮询 ── */
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -38,64 +60,147 @@ const AiAvatarPage: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
/* ── 对口型 ── */
/* ── 步骤切换 ── */
const canGoToStep2 = useCallback(() => {
return state.selectedVideo && state.selectedVoice && state.scriptText.trim()
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
const handleNextStep = useCallback(() => {
if (!canGoToStep2()) {
message.warning("请先完成出镜视频、配音和文案的选择")
return
}
setCurrentStep(2)
}, [canGoToStep2])
const handlePrevStep = useCallback(() => {
setCurrentStep(1)
}, [])
/* ── 对口型 ─ */
const handleGenerateLipsync = useCallback(async () => {
if (!state.selectedVideo || !state.selectedVoice || !state.scriptText) return
const video = state.selectedVideo
const voice = state.selectedVoice
const text = state.scriptText.trim()
const missing: string[] = []
if (!video) missing.push("出镜视频")
if (!voice) missing.push("音色")
if (!text) missing.push("文案")
if (missing.length > 0 || !video || !voice) {
message.warning(`请先选择${missing.join("、")}`)
return
}
try {
const job = await createLipsyncJob({
voice_id: state.selectedVoice.voice_id,
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
console.log("[对口型] 开始生成:", {
videoId: video.id,
voiceId: voice.voice_id,
voiceType: voice.type,
textLen: state.scriptText.length,
})
const asset = await getAssetById(video.id)
console.log("[对口型] getAssetById 响应:", {
id: asset?.id,
file_url: asset?.file_url?.substring(0, 100),
})
const videoUrl = asset?.file_url
if (!videoUrl) {
console.error("[对口型] file_url 为空,asset:", asset)
setShowLipsyncModal(false)
message.error("获取出镜视频播放地址失败,请重新选择素材")
return
}
const payload = {
voice_id: voice.voice_id,
script_text: state.scriptText,
video_asset_id: state.selectedVideo.id,
video_url: videoUrl,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
}
console.log("[对口型] createLipsyncJob 请求:", payload)
const job = await createLipsyncJob(payload)
console.log("[对口型] createLipsyncJob 响应:", {
id: job.id,
status: job.status,
})
state.setLipsyncJob(job)
// 开始轮询
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = setInterval(async () => {
try {
const updated = await getLipsyncJob(job.id)
state.setLipsyncJob(updated)
if (updated.status === "completed" || updated.status === "failed") {
console.log("[对口型] 轮询状态:", {
id: updated.id,
status: updated.status,
error: updated.error_message,
})
if (updated.status === "completed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
setLipsyncStatus("completed")
setTimeout(() => {
setShowLipsyncModal(false)
message.success("对口型视频生成完成")
}, 1000)
} else if (updated.status === "failed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
setLipsyncStatus("failed")
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
}
} catch {
// 忽略轮询错误
} catch (err) {
console.error("[对口型] 轮询错误:", err)
}
}, 3000)
} catch (err) {
console.error("对口型任务创建失败:", err)
console.error("[对口型] 创建失败:", {
status: (err as { response?: { status?: number } })?.response?.status,
data: (err as { response?: { data?: unknown } })?.response?.data,
message: err instanceof Error ? err.message : String(err),
})
setShowLipsyncModal(false)
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
const handleCancelLipsync = useCallback(() => {
if (lipsyncTimerRef.current) {
clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = null
}
setShowLipsyncModal(false)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
}, [])
// 清理轮询
useEffect(() => {
return () => {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
}
}, [])
/* ── 加载 B-roll 素材 ── */
useEffect(() => {
getAssetsByKind("video", { limit: 50 })
.then(setBRollAssets)
.catch(() => {})
}, [])
/* ── 生成视频 ── */
const handleGenerate = useCallback(async () => {
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") return
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
message.warning("请先生成对口型视频,待对口型完成后再提交渲染")
return
}
state.setIsGenerating(true)
try {
await submitRender({
lipsync_job_id: state.lipsyncJob.id,
script_id: state.script?.id,
b_roll_segments: state.bRollSegments as never,
title_config: state.titleConfig as unknown as Record<string, unknown>,
cover_config: state.coverConfig as unknown as Record<string, unknown>,
title_config: buildTitleConfigPayload(state.titleConfig),
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
resolution: state.resolution,
})
message.success("渲染任务已提交,可在视频管理中查看进度")
} catch (err) {
console.error("渲染任务提交失败:", err)
message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试")
} finally {
state.setIsGenerating(false)
}
@@ -109,6 +214,36 @@ const AiAvatarPage: React.FC = () => {
state.resolution,
])
/* ── 智能封面 ── */
const handleSmartCover = useCallback(async () => {
const videoUrl = state.lipsyncJob?.output_video_url
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
message.warning("请先生成对口型视频,完成后再智能获取封面")
return
}
setSmartCoverLoading(true)
try {
const res = await generateSmartCover(videoUrl, 5)
if (res.cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
} else {
message.error(res.message || "智能封面生成失败,请稍后重试")
}
} catch (err) {
console.error("智能封面生成失败:", err)
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
} finally {
setSmartCoverLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.lipsyncJob])
/* ── 配置汇总 ── */
const summary = {
videoName: state.selectedVideo?.name || null,
@@ -123,97 +258,157 @@ const AiAvatarPage: React.FC = () => {
return (
<div className="aa-page">
<div className="aa-page-header">
<h1>AI数字人</h1>
<h1>AI </h1>
</div>
{/* 步骤指示器 */}
<div className="aa-steps">
<div className={`aa-step ${currentStep >= 1 ? "active" : ""}`}>
<span className="aa-step__number">1</span>
<span className="aa-step__label"> / / </span>
</div>
<div className="aa-step__connector" />
<div className={`aa-step ${currentStep >= 2 ? "active" : ""}`}>
<span className="aa-step__number">2</span>
<span className="aa-step__label"> / / / </span>
</div>
</div>
<div className="aa-page-body">
{/* 面板1:出镜视频 */}
<div className={`aa-panel aa-panel--p1${collapsed.video ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelVideoSelector
selectedVideo={state.selectedVideo}
onSelectVideo={() => state.setShowAssetPicker(true)}
onRemoveVideo={state.removeVideo}
/>
</div>
</div>
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ═══ */}
{currentStep === 1 && (
<>
{/* 面板 1:出镜视频 */}
<div className={`aa-panel${collapsed.video ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelVideoSelector
selectedVideo={state.selectedVideo}
onSelectVideo={() => state.setShowAssetPicker(true)}
onRemoveVideo={state.removeVideo}
titleConfig={state.titleConfig}
/>
</div>
</div>
{/* 面板2:配音库 */}
<div className={`aa-panel aa-panel--p2${collapsed.voice ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelVoiceSelector
voiceSource={state.voiceSource}
onVoiceSourceChange={state.setVoiceSource}
selectedVoice={state.selectedVoice}
onSelectVoice={state.setSelectedVoice}
emotion={state.emotion}
onEmotionChange={state.setEmotion}
speed={state.speed}
onSpeedChange={state.setSpeed}
language={state.language}
onLanguageChange={state.setLanguage}
/>
</div>
</div>
{/* 面板 2:配音库 */}
<div className={`aa-panel${collapsed.voice ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelVoiceSelector
voiceSource={state.voiceSource}
onVoiceSourceChange={state.setVoiceSource}
selectedVoice={state.selectedVoice}
onSelectVoice={state.setSelectedVoice}
emotion={state.emotion}
onEmotionChange={state.setEmotion}
speed={state.speed}
onSpeedChange={state.setSpeed}
language={state.language}
onLanguageChange={state.setLanguage}
/>
</div>
</div>
{/* 面板3:文案 & 对口型 */}
<div className={`aa-panel aa-panel--p3${collapsed.script ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelScriptAndLipsync
scriptText={state.scriptText}
onScriptTextChange={state.setScriptText}
onOpenScriptModal={() => state.setShowScriptModal(true)}
lipsyncJob={state.lipsyncJob}
onGenerateLipsync={handleGenerateLipsync}
bRollSegments={state.bRollSegments}
onOpenBRollModal={() => state.setShowBRollModal(true)}
onRemoveBRoll={state.removeBRollSegment}
/>
</div>
</div>
{/* 面板 3:文案 */}
<div className={`aa-panel${collapsed.script ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelScript
scriptText={state.scriptText}
onScriptTextChange={state.setScriptText}
onOpenScriptModal={() => state.setShowScriptModal(true)}
/>
</div>
</div>
{/* 面板4:标题配置 */}
<div className={`aa-panel aa-panel--p4${collapsed.title ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelTitleConfig titleConfig={state.titleConfig} onUpdate={state.updateTitleConfig} />
</div>
</div>
{/* 步骤 1 底部按钮 */}
<div className="aa-step-actions">
<button className="aa-btn aa-btn--primary" onClick={handleNextStep}>
</button>
</div>
</>
)}
{/* 面板5:封面 & 生成 */}
<div className={`aa-panel aa-panel--p5${collapsed.cover ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelCoverAndGenerate
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
summary={summary}
/>
</div>
</div>
{/* ════ 步骤 2:插入画面 / 对口型预览 / 标题配置 / 封面&生成 ═══ */}
{currentStep === 2 && (
<>
{/* 面板 4:插入画面 & 对口型预览 */}
<div className={`aa-panel${collapsed.lipsync ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("lipsync")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelLipsyncPreview
lipsyncJob={state.lipsyncJob}
onGenerateLipsync={handleGenerateLipsync}
bRollSegments={state.bRollSegments}
onOpenBRollModal={() => state.setShowBRollModal(true)}
onRemoveBRoll={state.removeBRollSegment}
/>
</div>
</div>
{/* 面板 5:标题配置 */}
<div className={`aa-panel${collapsed.title ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelTitleConfig
titleConfig={state.titleConfig}
onUpdate={state.updateTitleConfig}
/>
</div>
</div>
{/* 面板 6:封面 & 生成 */}
<div className={`aa-panel${collapsed.cover ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelCoverAndGenerate
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({
...prev,
...partial,
}))
}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={state.lipsyncJob?.status === "completed"}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
summary={summary}
/>
</div>
</div>
{/* 步骤 2 底部按钮 */}
<div className="aa-step-actions">
<button className="aa-btn" onClick={handlePrevStep}>
</button>
</div>
</>
)}
</div>
{/* 素材库弹窗 */}
@@ -241,11 +436,110 @@ const AiAvatarPage: React.FC = () => {
open={state.showBRollModal}
onClose={() => state.setShowBRollModal(false)}
existingSegments={state.bRollSegments}
availableAssets={bRollAssets}
scriptText={state.scriptText}
outputDuration={state.lipsyncJob?.output_duration ?? 0}
onConfirm={state.addBRollSegment}
onRemove={state.removeBRollSegment}
/>
)}
{/* 对口型生成弹窗 */}
{showLipsyncModal && (
<div className="aa-modal-overlay">
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
<button className="aa-modal__close" onClick={handleCancelLipsync}>
</button>
</div>
<div
className="aa-modal__body"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "40px 20px",
}}
>
{lipsyncStatus === "generating" && (
<>
<div className="aa-lipsync-spinner" />
<div
style={{
marginTop: 20,
fontSize: 15,
color: "#1a1a2e",
}}
>
</div>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#8c8ca1",
}}
>
</div>
</>
)}
{lipsyncStatus === "completed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div
style={{
marginTop: 16,
fontSize: 15,
color: "#1a1a2e",
}}
>
</div>
</>
)}
{lipsyncStatus === "failed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div
style={{
marginTop: 16,
fontSize: 15,
color: "#1a1a2e",
}}
>
</div>
{lipsyncErrorMessage && (
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#ff4d4f",
}}
>
{lipsyncErrorMessage}
</div>
)}
</>
)}
</div>
<div className="aa-modal__footer">
{lipsyncStatus === "generating" && (
<button className="aa-btn aa-btn--danger" onClick={handleCancelLipsync}>
</button>
)}
{lipsyncStatus !== "generating" && (
<button className="aa-btn" onClick={handleCancelLipsync}>
</button>
)}
</div>
</div>
</div>
)}
</div>
)
}
@@ -264,8 +558,8 @@ const ScriptSelectModalLazy: React.FC<{
if (!open) return
setLoading(true)
getScripts()
.then(setScripts)
.catch(() => {})
.then((items) => setScripts(Array.isArray(items) ? items : []))
.catch(() => setScripts([]))
.finally(() => setLoading(false))
}, [open])
+36 -5
View File
@@ -1,13 +1,17 @@
/**
* AI数字人 — API 封装
* AI数字人 — API 封装#1822 契约对齐)
*/
import apiClient from "@/api/client"
import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types"
/* ── 文案库 ── */
export const getScripts = async (): Promise<Script[]> => {
const response = await apiClient.get<Script[]>("/scripts")
return response.data
const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts")
// 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复)
const data = response.data as unknown
if (Array.isArray(data)) return data
const items = (data as { items?: Script[] })?.items
return Array.isArray(items) ? items : []
}
export const getScriptById = async (id: string): Promise<Script> => {
@@ -24,11 +28,26 @@ export const deleteScript = async (id: string): Promise<void> => {
await apiClient.delete(`/scripts/${id}`)
}
/* ── 对口型 ── */
/* ── 素材单查(拿到 file_url 作为对口型的 video_url ── */
export const getAssetById = async (id: string): Promise<{ file_url?: string; id: string }> => {
const response = await apiClient.get<{ file_url?: string; id: string }>(`/assets/${id}`)
return response.data
}
/* ── 对口型(模式A:TTS 直生,后端内部合成音频;不要先调 TTS 拿 audio_url ── */
export const createLipsyncJob = async (data: {
/** 人物视频 URLMP4);由素材 id 经 getAssetById 拿 file_url,禁止传 video_asset_id */
video_url: string
/** 音色 ID(预置音色 或 克隆音色 profile UUID,后端会解析) */
voice_id: string
/** 要合成的文案(手动输入或文案库内容) */
script_text: string
video_asset_id: string
/** 语速 0.5~2.0,默认 1.0 */
speed?: number
/** 情绪英文枚举:natural/excited/calm/friendly */
emotion?: string
enable_video_loop?: boolean
project_id?: string
}): Promise<LipsyncJob> => {
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
return response.data
@@ -39,6 +58,18 @@ export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
return response.data
}
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧,独立于渲染任务) ── */
export const generateSmartCover = async (
video_url: string,
max_frames = 5,
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
"/ai-avatar/render/smart-cover",
{ video_url, max_frames },
)
return response.data
}
/* ── 渲染 ── */
export const submitRender = async (data: {
lipsync_job_id: string
@@ -1,13 +1,10 @@
/**
* AI数字人 — 素材库弹窗
* 搜索框 + 类型筛选(全部/视频/图片)+ 4 列竖屏 9:16 缩略图网格 + 底部确认选择
* AI数字人 — 出镜视频选择弹窗(#1809 ③)
* 交互对齐智能剪辑 Step2:先选素材库(video 库)→ 再选该库内视频。
* 搜索框 + 素材库下拉 + 竖屏 9:16 视频缩略图网格 + 底部确认选择。
*/
import { useEffect, useState } from "react"
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
/** 素材类型筛选 */
type AssetKindFilter = "all" | "video" | "image"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
export interface ModalAssetPickerProps {
open: boolean
@@ -17,86 +14,76 @@ export interface ModalAssetPickerProps {
selectedId?: string
}
const KIND_OPTIONS: { value: AssetKindFilter; label: string }[] = [
{ value: "all", label: "全部" },
{ value: "video", label: "视频" },
{ value: "image", label: "图片" },
]
export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalAssetPickerProps) {
const [keyword, setKeyword] = useState("")
const [kindFilter, setKindFilter] = useState<AssetKindFilter>("video")
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
const [libraryId, setLibraryId] = useState<string>("")
const [assets, setAssets] = useState<AssetItem[]>([])
const [pickedId, setPickedId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [loadingLibs, setLoadingLibs] = useState(false)
const [loadingAssets, setLoadingAssets] = useState(false)
const [error, setError] = useState("")
const [ready, setReady] = useState(false)
/* 弹窗打开:重置筛选 / 关键字,并定位高亮到已选素材 */
/* 弹窗打开:重置状态 */
useEffect(() => {
if (!open) return
setKeyword("")
setKindFilter("video")
setLibraries([])
setLibraryId("")
setAssets([])
setError("")
setPickedId(selectedId ?? null)
setReady(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
/* 确保默认素材库存在(视频 + 图片,供类型筛选),仅在弹窗打开时执行一次 */
/* 第一步:加载视频素材库列表(仅 kind=video,对齐智能剪辑 #1777 */
useEffect(() => {
if (!open) return
let cancelled = false
const ensureLibraries = async () => {
try {
const project = await getOrCreateDefaultProject()
await Promise.all([
ensureDefaultLibrary({ project_id: project.id, kind: "video" }),
ensureDefaultLibrary({ project_id: project.id, kind: "image" }),
])
if (!cancelled) setReady(true)
} catch {
if (!cancelled) setError("素材库初始化失败,请重试")
}
}
ensureLibraries()
setLoadingLibs(true)
getAssetLibraries("video")
.then((libs) => {
if (cancelled) return
const list = Array.isArray(libs) ? libs : []
setLibraries(list)
// 默认选中第一个视频库
if (list.length > 0) setLibraryId((prev) => prev || list[0].id)
})
.catch(() => {
if (!cancelled) setError("素材库加载失败,请重试")
})
.finally(() => {
if (!cancelled) setLoadingLibs(false)
})
return () => {
cancelled = true
}
}, [open])
/* 拉取素材:类型 / 关键字变化时防抖重新请求 */
/* 第二步:选中库后拉取该库视频素材(关键字防抖) */
useEffect(() => {
if (!open || !ready) return
if (!open || !libraryId) return
let cancelled = false
setLoading(true)
setLoadingAssets(true)
const load = async () => {
try {
const kw = keyword.trim() || undefined
let items: AssetItem[] = []
if (kindFilter === "all") {
const [videos, images] = await Promise.all([
getAssetsByKind("video", { keyword: kw }),
getAssetsByKind("image", { keyword: kw }),
])
const seen = new Set<string>()
items = [...videos, ...images].filter((a) => {
if (seen.has(a.id)) return false
seen.add(a.id)
return true
})
} else {
items = await getAssetsByKind(kindFilter, { keyword: kw })
}
if (!cancelled) setAssets(items)
// getAssets 返回 { items, total };拉满一页(数字人口播视频库通常不大)
const { items } = await getAssets(libraryId, { page_size: 100 })
if (cancelled) return
let list = Array.isArray(items) ? items : []
// 仅保留视频素材(出镜视频要求)
list = list.filter((a) => a.mime_type?.includes("video"))
const kw = keyword.trim()
if (kw) list = list.filter((a) => a.name?.includes(kw))
setAssets(list)
setError("")
} catch {
if (!cancelled) {
setError("素材加载失败,请重试")
setAssets([])
}
} finally {
if (!cancelled) setLoading(false)
if (!cancelled) setLoadingAssets(false)
}
}
const timer = window.setTimeout(load, 300)
@@ -104,7 +91,7 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
cancelled = true
window.clearTimeout(timer)
}
}, [open, ready, kindFilter, keyword])
}, [open, libraryId, keyword])
if (!open) return null
@@ -120,15 +107,33 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
{/* 头部 */}
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
<span className="aa-modal__title"></span>
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
{/* 主体:搜索 + 筛选 + 网格 */}
{/* 主体:素材库选择 + 搜索 + 网格 */}
<div className="aa-modal__body">
{/* 第一步:选素材库 */}
<div className="aa-asset-search">
<select
className="aa-select"
style={{ width: 160, flex: "0 0 auto" }}
value={libraryId}
onChange={(e) => setLibraryId(e.target.value)}
disabled={loadingLibs || libraries.length === 0}
>
{libraries.length === 0 ? (
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
) : (
libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
📁 {lib.name}
</option>
))
)}
</select>
<input
className="aa-input"
type="text"
@@ -136,21 +141,14 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<select
className="aa-select"
style={{ width: 110, flex: "0 0 auto" }}
value={kindFilter}
onChange={(e) => setKindFilter(e.target.value as AssetKindFilter)}
>
{KIND_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{loading ? (
{libraries.length === 0 && !loadingLibs ? (
<div className="aa-empty">
<div className="aa-empty__icon">📁</div>
</div>
) : loadingAssets ? (
<div className="aa-empty">
<div className="aa-empty__icon"></div>
@@ -162,8 +160,8 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
</div>
) : assets.length === 0 ? (
<div className="aa-empty">
<div className="aa-empty__icon">📁</div>
<div className="aa-empty__icon">🎬</div>
</div>
) : (
<div className="aa-asset-grid">
@@ -1,23 +1,27 @@
/**
* AI数字人 — B-roll 画面插入编辑器弹窗
* AI数字人 — B-roll 画面插入编辑器弹窗#1809 ④⑤⑥)
*
* 布局:
* - 左侧:可用素材网格(已被其他 segment 使用的素材标灰 + "已选择" 遮罩,
* pointer-events: none 防重复选择同一段素材
* - 右侧:插入设置(文案段落索引 / 全屏 or 画中画 / 画中画四角位置 + 大小 / 起止时间)
* - 底部:已配置的画面插入列表(可删除)+ 上传新素材入口
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算)
* - 底部:已配置的画面插入列表(可删除)
*/
import React, { useMemo, useState } from "react"
import type { AssetItem } from "@/api/assets"
import React, { useEffect, useMemo, useState } from "react"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
interface ModalBRollEditorProps {
open: boolean
onClose: () => void
/** 当前已有的 B-roll segments(用于标灰已选素材) */
existingSegments: BRollSegment[]
/** 所有可用素材 */
availableAssets: AssetItem[]
/** 当前文案全文(用于分句) */
scriptText: string
/** 对口型成片总时长(秒),用于时间自动估算 */
outputDuration: number
onConfirm: (segment: BRollSegment) => void
onRemove: (id: string) => void
}
@@ -38,18 +42,31 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
open,
onClose,
existingSegments,
availableAssets,
scriptText,
outputDuration,
onConfirm,
onRemove,
}) => {
/* ── 素材库(④ 先选库再选素材) ── */
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
const [libraryId, setLibraryId] = useState<string>("")
const [availableAssets, setAvailableAssets] = useState<AssetItem[]>([])
const [loadingLibs, setLoadingLibs] = useState(false)
const [loadingAssets, setLoadingAssets] = useState(false)
const [assetError, setAssetError] = useState("")
/* ── 右侧设置本地状态 ── */
const [selectedAsset, setSelectedAsset] = useState<AssetItem | null>(null)
const [scriptSegmentIndex, setScriptSegmentIndex] = useState(0)
const [selectedSentence, setSelectedSentence] = useState<ScriptSentence | null>(null)
const [mode, setMode] = useState<BRollInsertMode>("fullscreen")
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
const [pipScale, setPipScale] = useState(0.3)
const [startTime, setStartTime] = useState(0)
const [endTime, setEndTime] = useState(3)
/** 文案分句(⑤) */
const sentences = useMemo(
() => splitScriptIntoSentences(scriptText, outputDuration),
[scriptText, outputDuration],
)
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
const usedAssetIds = useMemo(
@@ -57,25 +74,83 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
[existingSegments],
)
/* 弹窗打开:重置选择 + 加载视频库列表 */
useEffect(() => {
if (!open) return
setSelectedAsset(null)
setSelectedSentence(null)
setMode("fullscreen")
setPipPosition("top-right")
setPipScale(0.3)
setLibraries([])
setLibraryId("")
setAvailableAssets([])
setAssetError("")
setLoadingLibs(true)
let cancelled = false
getAssetLibraries("video")
.then((libs) => {
if (cancelled) return
const list = Array.isArray(libs) ? libs : []
setLibraries(list)
if (list.length > 0) setLibraryId(list[0].id)
})
.catch(() => {
if (!cancelled) setAssetError("素材库加载失败,请重试")
})
.finally(() => {
if (!cancelled) setLoadingLibs(false)
})
return () => {
cancelled = true
}
}, [open])
/* 选中库后拉取该库视频素材 */
useEffect(() => {
if (!open || !libraryId) return
let cancelled = false
setLoadingAssets(true)
getAssets(libraryId, { page_size: 100 })
.then(({ items }) => {
if (cancelled) return
const list = (Array.isArray(items) ? items : []).filter((a) =>
a.mime_type?.includes("video"),
)
setAvailableAssets(list)
setAssetError("")
})
.catch(() => {
if (!cancelled) {
setAssetError("素材加载失败,请重试")
setAvailableAssets([])
}
})
.finally(() => {
if (!cancelled) setLoadingAssets(false)
})
return () => {
cancelled = true
}
}, [open, libraryId])
if (!open) return null
/** 选择素材(已选素材因 pointer-events:none 不会触发) */
const handleSelectAsset = (asset: AssetItem) => {
if (usedAssetIds.has(asset.id)) return
setSelectedAsset(asset)
// 默认起止时间:素材时长的前 3 秒(或整段)
const dur = asset.duration ?? 3
setEndTime(Math.min(3, dur))
}
/** 确认添加一段 B-roll */
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止) */
const handleConfirm = () => {
if (!selectedAsset) return
if (endTime <= startTime) return
if (!selectedAsset || !selectedSentence) return
const startTime = selectedSentence.startTime
const endTime = Math.max(selectedSentence.endTime, startTime + 0.5)
const segment: BRollSegment = {
id: crypto.randomUUID(),
asset: selectedAsset,
script_segment_index: scriptSegmentIndex,
script_segment_index: selectedSentence.index,
start_time: startTime,
end_time: endTime,
mode,
@@ -83,15 +158,16 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
pip_scale: mode === "pip" ? pipScale : 0.3,
}
onConfirm(segment)
// 重置选择,保留设置便于连续添加
// 重置素材/句子选择,保留模式设置便于连续添加
setSelectedAsset(null)
setSelectedSentence(null)
}
const canConfirm = selectedAsset !== null && endTime > startTime
const canConfirm = selectedAsset !== null && selectedSentence !== null
return (
<div className="aa-modal-overlay" onClick={onClose}>
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
<div className="aa-modal aa-modal--wide" onClick={(e) => e.stopPropagation()}>
{/* 头部 */}
<div className="aa-modal__header">
<span className="aa-modal__title">🎞 B-roll</span>
@@ -103,22 +179,25 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
{/* 主体:左素材 + 右设置 */}
<div className="aa-modal__body">
<div className="aa-broll-modal-body">
{/* 左侧:素材网格 */}
{/* 左侧:选库 + 素材网格 */}
<div className="aa-broll-left">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 10,
}}
>
<span style={{ fontSize: 13, fontWeight: 600, color: "#1a1a2e" }}>
{availableAssets.length}
</span>
<button type="button" className="aa-btn aa-btn--ghost aa-btn-sm">
</button>
<div className="aa-broll-lib-row">
<select
className="aa-select"
value={libraryId}
onChange={(e) => setLibraryId(e.target.value)}
disabled={loadingLibs || libraries.length === 0}
>
{libraries.length === 0 ? (
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
) : (
libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
📁 {lib.name}
</option>
))
)}
</select>
</div>
<div className="aa-broll-asset-grid">
{availableAssets.map((asset) => {
@@ -141,45 +220,60 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} />
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 18,
}}
>
🎬
</div>
<div className="aa-broll-asset-placeholder">🎬</div>
)}
<span className="aa-asset-card__name">{asset.name}</span>
</div>
)
})}
{availableAssets.length === 0 && (
{loadingAssets ? (
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
<div className="aa-empty__icon"></div>
</div>
) : availableAssets.length === 0 ? (
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
<div className="aa-empty__icon">🎬</div>
{assetError || "该素材库暂无视频素材"}
</div>
)}
) : null}
</div>
</div>
{/* 右侧:插入设置 */}
<div className="aa-broll-right">
<div className="aa-broll-settings">
{/* 文案段落索引 */}
{/* ⑤ 文案句子列表(替代段落索引数字框) */}
<div className="aa-form-field">
<label className="aa-label"></label>
<input
className="aa-input"
type="number"
min={0}
value={scriptSegmentIndex}
onChange={(e) => setScriptSegmentIndex(Math.max(0, Number(e.target.value)))}
/>
<label className="aa-label"></label>
{sentences.length === 0 ? (
<div className="aa-sentence-empty">
&
</div>
) : (
<div className="aa-sentence-list">
{sentences.map((sent) => {
const active = selectedSentence?.index === sent.index
return (
<button
key={sent.index}
type="button"
className={`aa-sentence-item${active ? " active" : ""}`}
onClick={() => setSelectedSentence(sent)}
title={sent.text}
>
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
<span className="aa-sentence-item__text">{sent.text}</span>
{outputDuration > 0 && (
<span className="aa-sentence-item__time">
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
</span>
)}
</button>
)
})}
</div>
)}
</div>
{/* 插入模式 */}
@@ -224,10 +318,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
</div>
</div>
<div className="aa-form-field">
<div
className="aa-field-label-row"
style={{ display: "flex", justifyContent: "space-between" }}
>
<div className="aa-field-label-row">
<label className="aa-label"></label>
<span style={{ fontSize: 12, color: "#8c8ca1" }}>
{Math.round(pipScale * 100)}%
@@ -246,41 +337,26 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
</>
)}
{/* 起止时间 */}
<div className="aa-form-field">
<label className="aa-label"></label>
<input
className="aa-input"
type="number"
min={0}
step={0.1}
value={startTime}
onChange={(e) => setStartTime(Math.max(0, Number(e.target.value)))}
/>
</div>
<div className="aa-form-field">
<label className="aa-label"></label>
<input
className="aa-input"
type="number"
min={0}
step={0.1}
value={endTime}
onChange={(e) => setEndTime(Math.max(0, Number(e.target.value)))}
/>
</div>
{/* 当前选中素材提示 */}
<div
style={{
fontSize: 12,
color: selectedAsset ? "#059669" : "#8c8ca1",
background: "#f8f8fc",
borderRadius: 6,
padding: "6px 8px",
}}
>
{selectedAsset ? `已选素材:${selectedAsset.name}` : "请从左侧选择一段素材"}
{/* 当前选择提示(⑥ 自动估算时间在这里展示) */}
<div className="aa-broll-hint">
{selectedAsset && selectedSentence ? (
<>
<div>{selectedAsset.name}</div>
<div>
{selectedSentence.index + 1} · {" "}
{selectedSentence.startTime.toFixed(1)}s -{" "}
{Math.max(
selectedSentence.endTime,
selectedSentence.startTime + 0.5,
).toFixed(1)}
s
</div>
</>
) : (
<div style={{ color: "#8c8ca1" }}>
{!selectedAsset ? "请从左侧选择一段素材" : "请在上方点选对应的文案句子"}
</div>
)}
</div>
</div>
</div>
@@ -304,7 +380,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
<div className="aa-broll-item__info">
<div style={{ fontWeight: 500, color: "#1a1a2e" }}>{seg.asset.name}</div>
<div style={{ color: "#8c8ca1", fontSize: 11 }}>
{seg.script_segment_index} · {MODE_LABEL[seg.mode]}
{seg.script_segment_index + 1} · {MODE_LABEL[seg.mode]}
{seg.mode === "pip" ? ` · ${seg.pip_position}` : ""} ·{" "}
{seg.start_time.toFixed(1)}s - {seg.end_time.toFixed(1)}s
</div>
@@ -17,6 +17,10 @@ interface PanelCoverAndGenerateProps {
onResolutionChange: (r: string) => void
isGenerating: boolean
onGenerate: () => void
/** 智能获取封面(MediaKit 选帧) */
onSmartCover: () => void
smartCoverLoading: boolean
canSmartCover: boolean
/** 配置汇总信息 */
summary: {
videoName: string | null
@@ -50,6 +54,9 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
onResolutionChange,
isGenerating,
onGenerate,
onSmartCover,
smartCoverLoading,
canSmartCover,
summary,
}) => {
const uploadInputRef = useRef<HTMLInputElement>(null)
@@ -69,9 +76,10 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
e.target.value = ""
}
/** 从视频截取(使用配置的帧时间,默认首帧 */
const handleCaptureFromVideo = () => {
/** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822 */
const handleSmartCover = () => {
onCoverConfigChange({ mode: "auto_frame" })
onSmartCover()
}
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
@@ -93,9 +101,11 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleCaptureFromVideo}
onClick={handleSmartCover}
disabled={smartCoverLoading || !canSmartCover}
title={canSmartCover ? "基于对口型成片智能选帧" : "请先完成对口型生成"}
>
🎬
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
@@ -1,14 +1,10 @@
/**
* AI数字人 & 4
* / 9:16+ B-roll
* AI数字人 2
* B-roll + + /
*/
import { useState } from "react"
import type { LipsyncJob, BRollSegment } from "../types"
interface PanelScriptAndLipsyncProps {
scriptText: string
onScriptTextChange: (text: string) => void
onOpenScriptModal: () => void
interface PanelLipsyncPreviewProps {
lipsyncJob: LipsyncJob | null
onGenerateLipsync: () => void
bRollSegments: BRollSegment[]
@@ -16,8 +12,6 @@ interface PanelScriptAndLipsyncProps {
onRemoveBRoll: (id: string) => void
}
type ScriptTab = "library" | "manual"
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
fullscreen: "全屏",
pip: "画中画",
@@ -29,19 +23,13 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`
}
export function PanelScriptAndLipsync({
scriptText,
onScriptTextChange,
onOpenScriptModal,
export function PanelLipsyncPreview({
lipsyncJob,
onGenerateLipsync,
bRollSegments,
onOpenBRollModal,
onRemoveBRoll,
}: PanelScriptAndLipsyncProps) {
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
/* 对口型状态判断 */
}: PanelLipsyncPreviewProps) {
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
const isDone = lipsyncJob?.status === "completed"
const isFailed = lipsyncJob?.status === "failed"
@@ -54,47 +42,7 @@ export function PanelScriptAndLipsync({
: "对口型生成中…"
return (
<div className="aa-script-lipsync">
{/* ── 上半区:文案 ── */}
<div className="aa-script-tabs">
<button
type="button"
className={`aa-script-tab${scriptTab === "library" ? " active" : ""}`}
onClick={() => setScriptTab("library")}
>
</button>
<button
type="button"
className={`aa-script-tab${scriptTab === "manual" ? " active" : ""}`}
onClick={() => setScriptTab("manual")}
>
</button>
</div>
{scriptTab === "library" && (
<button
type="button"
className="aa-btn aa-btn--ghost aa-btn--full"
style={{ marginBottom: 8 }}
onClick={onOpenScriptModal}
>
📚
</button>
)}
<textarea
className="aa-textarea"
value={scriptText}
readOnly={scriptTab === "library"}
placeholder={
scriptTab === "library" ? "点击上方按钮,从文案库选择文案…" : "请输入数字人口播文案…"
}
onChange={(e) => onScriptTextChange(e.target.value)}
/>
<div className="aa-char-count">{scriptText.length} </div>
<div className="aa-lipsync-preview-panel">
{/* ── B-roll 画面 ── */}
<div className="aa-lipsync-section">
<div className="aa-lipsync-section__title">
@@ -157,7 +105,7 @@ export function PanelScriptAndLipsync({
)}
</div>
{/* ── 下半区:对口型预览(竖屏 9:16─ */}
{/* ── 对口型预览 ─ */}
<div className="aa-lipsync-section">
<div className="aa-lipsync-section__title"></div>
@@ -183,7 +131,13 @@ export function PanelScriptAndLipsync({
<div style={{ fontSize: 28, marginBottom: 8 }}></div>
<div></div>
{lipsyncJob?.error_message && (
<div style={{ fontSize: 11, marginTop: 4, color: "#fca5a5" }}>
<div
style={{
fontSize: 11,
marginTop: 4,
color: "#fca5a5",
}}
>
{lipsyncJob.error_message}
</div>
)}
@@ -219,4 +173,4 @@ export function PanelScriptAndLipsync({
)
}
export default PanelScriptAndLipsync
export default PanelLipsyncPreview
+67
View File
@@ -0,0 +1,67 @@
/**
* AI数字人 — 文案面板(步骤1用)
* 文案库选择 / 手动输入 + 字数统计
*/
import { useState } from "react"
interface PanelScriptProps {
scriptText: string
onScriptTextChange: (text: string) => void
onOpenScriptModal: () => void
}
type ScriptTab = "library" | "manual"
export function PanelScript({
scriptText,
onScriptTextChange,
onOpenScriptModal,
}: PanelScriptProps) {
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
return (
<div className="aa-script-panel">
{/* ── Tab 切换 ── */}
<div className="aa-script-tabs">
<button
type="button"
className={`aa-script-tab${scriptTab === "library" ? " active" : ""}`}
onClick={() => setScriptTab("library")}
>
</button>
<button
type="button"
className={`aa-script-tab${scriptTab === "manual" ? " active" : ""}`}
onClick={() => setScriptTab("manual")}
>
</button>
</div>
{scriptTab === "library" && (
<button
type="button"
className="aa-btn aa-btn--ghost aa-btn--full"
style={{ marginBottom: 8 }}
onClick={onOpenScriptModal}
>
📚
</button>
)}
<textarea
className="aa-textarea"
value={scriptText}
readOnly={scriptTab === "library"}
placeholder={
scriptTab === "library" ? "点击上方按钮,从文案库选择文案…" : "请输入数字人口播文案…"
}
onChange={(e) => onScriptTextChange(e.target.value)}
/>
<div className="aa-char-count">{scriptText.length} </div>
</div>
)
}
export default PanelScript
@@ -7,16 +7,17 @@
* - AiAvatarTitleConfig ↔ TitleSettings 的双向适配
* - 自动生成字幕开关
*/
import React, { useMemo, useState } from "react"
import React, { useMemo, useState, useEffect } from "react"
import { Input } from "antd"
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete"
import type { TitleSettings } from "@/pages/generate/types"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
TITLE_PRESETS,
getFontFamily,
} from "@/pages/generate/constants"
import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants"
import type { AiAvatarTitleConfig } from "../types"
import { getTitles } from "@/api/titles"
const { TextArea } = Input
interface PanelTitleConfigProps {
titleConfig: AiAvatarTitleConfig
@@ -27,6 +28,14 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
const [activePreset, setActivePreset] = useState<string | null>(null)
/** 标题库选项(复用智能剪辑的标题库) */
const [titleOptions, setTitleOptions] = useState<TitleOption[]>([])
useEffect(() => {
getTitles()
.then((items) => setTitleOptions(items.map((t) => ({ label: t.content, value: t.content }))))
.catch(() => setTitleOptions([]))
}, [])
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
const titleSettings: TitleSettings = useMemo(
() => ({
@@ -62,37 +71,32 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
return (
<div className="aa-title-config">
{/* 主标题输入 */}
{/* 主标题输入 — TextArea 多行 + 标题库选择 */}
<div className="aa-form-field">
<label className="aa-label"></label>
<input
className="aa-input aa-title-input"
type="text"
placeholder="输入视频标题(留空则不显示标题)"
<TextArea
className="aa-title-input"
placeholder="输入视频标题(支持 / 分行)"
value={titleConfig.title}
maxLength={30}
autoSize={{ minRows: 2, maxRows: 4 }}
maxLength={200}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{ fontSize: 15 }}
/>
{titleConfig.title && (
<div
style={{
fontSize: 13,
padding: "6px 8px",
background: "#f8f8fc",
borderRadius: 6,
fontFamily: getFontFamily(titleConfig.font),
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
color: titleConfig.color,
textShadow: titleConfig.shadow ? "1px 1px 3px rgba(0,0,0,0.6)" : undefined,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "#8c8ca1", whiteSpace: "nowrap" }}>📚 </span>
<TitleLibraryAutoComplete
key={titleConfig.title}
placeholder="选择标题填入上方"
value=""
onChange={(val) => {
if (val) onUpdate({ title: val })
}}
>
{titleConfig.title}
</div>
)}
options={titleOptions}
maxLength={200}
style={{ flex: 1 }}
/>
</div>
</div>
{/* 标题样式:直接复用智能剪辑 TitleStylePanel(位置/字体/字号/样式/预设) */}
@@ -4,12 +4,15 @@
* - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮
*/
import type { AssetItem } from "@/api/assets"
import type { AiAvatarTitleConfig } from "../types"
import { getFontFamily } from "@/pages/generate/constants"
export interface PanelVideoSelectorProps {
selectedVideo: AssetItem | null
/** 触发打开素材库弹窗 */
onSelectVideo: () => void
onRemoveVideo: () => void
titleConfig?: AiAvatarTitleConfig
}
/** 格式化时长(秒 → mm:ss */
@@ -24,6 +27,7 @@ export function PanelVideoSelector({
selectedVideo,
onSelectVideo,
onRemoveVideo,
titleConfig,
}: PanelVideoSelectorProps) {
/* 未选视频:虚线上传区,点击打开素材库弹窗 */
if (!selectedVideo) {
@@ -53,13 +57,42 @@ export function PanelVideoSelector({
return (
<div>
{/* 竖屏 9:16 视频预览播放器 */}
<div className="aa-video-preview">
{/* 竖屏 9:16 视频预览播放器 + 标题实时预览 */}
<div className="aa-video-preview" style={{ position: "relative" }}>
{fileUrl ? (
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
) : (
<div className="aa-video-preview__placeholder"></div>
)}
{titleConfig?.title && (
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
...(titleConfig.position === "top"
? { top: "10%" }
: titleConfig.position === "bottom"
? { bottom: "10%" }
: { top: "50%", transform: "translate(-50%, -50%)" }),
fontSize: Math.max(titleConfig.size, 32),
fontFamily: getFontFamily(titleConfig.font),
color: titleConfig.color,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textShadow: "0 2px 4px rgba(0,0,0,0.5)",
WebkitTextStroke: "2px #000",
pointerEvents: "none",
zIndex: 10,
maxWidth: "90%",
textAlign: "center",
whiteSpace: "pre-wrap",
lineHeight: 1.3,
}}
>
{titleConfig.title}
</div>
)}
</div>
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
@@ -3,7 +3,10 @@
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
*/
import { useEffect, useRef, useState } from "react"
import { message } from "antd"
import { fetchVoices } from "@/api/voices/voices"
import { previewTts } from "@/api/tts"
import { normalizeEmotion } from "../utils/contract"
import type { UnifiedVoiceItem } from "@/api/voices/types"
import {
type VoiceSource,
@@ -43,6 +46,9 @@ export function PanelVoiceSelector({
const [error, setError] = useState<string | null>(null)
const [previewingId, setPreviewingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
/** 克隆音色试听合成缓存:voiceId -> url,对齐配音库 useAudioPlayer */
const previewCacheRef = useRef<Map<string, string>>(new Map())
const VOICE_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的配音效果,希望你喜欢。"
/* 切换来源时重新获取音色列表 */
useEffect(() => {
@@ -52,7 +58,7 @@ export function PanelVoiceSelector({
setError(null)
try {
const res = await fetchVoices({ type: voiceSource })
if (!cancelled) setVoices(res.items || [])
if (!cancelled) setVoices(Array.isArray(res?.items) ? res.items : [])
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "音色加载失败")
} finally {
@@ -83,21 +89,20 @@ export function PanelVoiceSelector({
setPreviewingId(null)
}
const handlePreview = (voice: UnifiedVoiceItem) => {
const url = voice.preview_url || voice.audio_url
if (!url) return
/* 再次点击当前试听音色 → 停止 */
if (previewingId === voice.id) {
stopPreview()
return
}
const NO_PREVIEW_TIP = "该音色暂无试听音频,请先用此音色生成一段配音后再试听"
/** 用指定 URL 真实播放(抽取公共) */
const playAudioUrl = (voiceId: string, url: string) => {
// 临时兼容:后端 /tts/preview 返回 HTTP URLstaging 是 HTTPSMixed Content 会阻止加载
// OSS 同时支持 HTTP/HTTPS,直接替换协议即可
const safeUrl = url.startsWith("http://") ? url.replace("http://", "https://") : url
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
const audio = new Audio(url)
const audio = new Audio(safeUrl)
audioRef.current = audio
setPreviewingId(voice.id)
setPreviewingId(voiceId)
audio.onended = () => {
if (audioRef.current === audio) {
audioRef.current = null
@@ -108,15 +113,80 @@ export function PanelVoiceSelector({
if (audioRef.current === audio) {
audioRef.current = null
setPreviewingId(null)
setError("试听音频加载失败")
message.error("试听音频加载失败")
}
}
void audio.play().catch(() => {
setPreviewingId(null)
setError("试听播放失败")
message.error("试听播放失败")
})
}
const handlePreview = async (voice: UnifiedVoiceItem) => {
/* 再次点击当前试听音色 → 停止 */
if (previewingId === voice.id) {
stopPreview()
return
}
/* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview
* 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */
if (voice.type === "clone") {
const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id)
if (cached) {
playAudioUrl(voice.id, cached)
return
}
const targetId = voice.voice_clone_profile_id || voice.id
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
console.log("[AI数字人-克隆试听] previewTts 请求:", {
voice_id: targetId,
voice_name: voice.name,
voice_type: voice.type,
voice_clone_profile_id: voice.voice_clone_profile_id,
voice_id_field: voice.voice_id,
})
setPreviewingId(voice.id)
try {
const res = await previewTts({
text: VOICE_PREVIEW_TEXT,
voice_id: targetId,
speed: speed, // 透传用户选择的语速(#1822)
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
})
console.log("[AI数字人-克隆试听] previewTts 响应:", {
audio_url: res.audio_url?.substring(0, 80),
duration: res.duration,
})
if (!res.audio_url) {
setPreviewingId(null)
message.error("合成试听失败:未返回音频")
return
}
previewCacheRef.current.set(targetId, res.audio_url)
playAudioUrl(voice.id, res.audio_url)
} catch (err) {
setPreviewingId(null)
// DEBUG: 打印详细错误信息
console.error("[AI数字人-克隆试听] previewTts 失败:", {
status: (err as { response?: { status?: number } })?.response?.status,
data: (err as { response?: { data?: unknown } })?.response?.data,
message: err instanceof Error ? err.message : String(err),
})
// apiClient 拦截器已统一 toast
}
return
}
/* 系统预设音色:沿用 preview_url/audio_url 直链播放 */
const url = voice.preview_url || voice.audio_url
if (!url) {
message.warning(NO_PREVIEW_TIP)
return
}
playAudioUrl(voice.id, url)
}
const handleSpeedChange = (value: string) => {
const parsed = parseFloat(value)
if (Number.isNaN(parsed)) return
@@ -182,7 +252,7 @@ export function PanelVoiceSelector({
type="button"
className="aa-voice-card__preview"
title={previewingId === voice.id ? "停止试听" : "试听"}
disabled={!previewUrl}
disabled={voice.type === "preset" && !previewUrl}
onClick={(e) => {
e.stopPropagation()
handlePreview(voice)
@@ -0,0 +1,97 @@
/**
* AI数字人 — 标题库选择弹窗
* 复用智能剪辑的标题库 API,选择标题后填入输入框
*/
import React, { useEffect, useState } from "react"
import { getTitles } from "@/api/titles"
import type { TitleItem } from "@/api/titles/types"
interface TitleLibraryModalProps {
open: boolean
onClose: () => void
onSelect: (title: string) => void
}
const TitleLibraryModal: React.FC<TitleLibraryModalProps> = ({ open, onClose, onSelect }) => {
const [titles, setTitles] = useState<TitleItem[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState("")
useEffect(() => {
if (!open) return
setLoading(true)
getTitles()
.then((items) => setTitles(items))
.catch(() => setTitles([]))
.finally(() => setLoading(false))
}, [open])
const filtered = titles.filter(
(t) => !search || t.content.toLowerCase().includes(search.toLowerCase()),
)
if (!open) return null
return (
<div className="aa-modal-overlay" onClick={onClose}>
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 600 }}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
<button className="aa-modal__close" onClick={onClose}></button>
</div>
<div className="aa-modal__body">
<div style={{ marginBottom: 12 }}>
<input
className="aa-input"
placeholder="搜索标题..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{loading ? (
<div style={{ textAlign: "center", padding: 40, color: "#8c8ca1" }}>...</div>
) : filtered.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#8c8ca1" }}>
</div>
) : (
<div style={{ maxHeight: 400, overflowY: "auto" }}>
{filtered.map((t) => (
<div
key={t.id}
style={{
padding: "12px 16px",
marginBottom: 8,
background: "#f8f8fc",
borderRadius: 8,
cursor: "pointer",
transition: "background 0.2s",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "#eef0ff")}
onMouseLeave={(e) => (e.currentTarget.style.background = "#f8f8fc")}
onClick={() => {
onSelect(t.content)
onClose()
}}
>
<div style={{ fontSize: 14, color: "#1a1a2e", marginBottom: 4 }}>{t.content}</div>
<div style={{ fontSize: 12, color: "#8c8ca1" }}>
{t.word_count ?? t.content.length} ·{" "}
{t.created_at ? new Date(t.created_at).toLocaleDateString() : ""}
</div>
</div>
))}
</div>
)}
</div>
<div className="aa-modal__footer">
<button className="aa-btn" onClick={onClose}>
</button>
</div>
</div>
</div>
)
}
export default TitleLibraryModal
+11 -1
View File
@@ -44,6 +44,8 @@ export interface LipsyncJob {
status: LipsyncStatus
progress: number
output_video_url: string | null
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
output_duration?: number
error_message: string | null
created_at: string
}
@@ -75,6 +77,9 @@ export interface AiAvatarTitleConfig {
shadow: boolean
color: string
auto_subtitle: boolean
/** 自定义位置坐标(position=custom 时生效,像素) */
pos_x?: number
pos_y?: number
}
/* ── 封面配置 ── */
@@ -84,6 +89,8 @@ export interface AiAvatarCoverConfig {
frame_time: number
upload_url: string | null
thumbnail_url: string | null
/** 智能封面(MediaKit 选帧)返回的 OSS 非临时 URL#1822 */
smart_cover_url: string | null
}
/* ── 渲染任务 ── */
@@ -101,7 +108,7 @@ export interface RenderJob {
/* ── 默认值 ── */
export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
title: "",
position: "top",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
@@ -110,6 +117,8 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
shadow: false,
color: "#ffffff",
auto_subtitle: true,
pos_x: undefined,
pos_y: undefined,
}
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
@@ -118,4 +127,5 @@ export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
frame_time: 0,
upload_url: null,
thumbnail_url: null,
smart_cover_url: null,
}
@@ -0,0 +1,76 @@
/**
* AI数字人 — 前后端接口契约转换工具(#1822)
*
* 以 packages/domain/video_filter_builder.py 的 build_title_drawtext_filter() 为唯一口径
* (契约文档第 5 节的 titles[]/fontSize/frame/start/end 为误写,后端不认,禁止使用)。
*/
import type { AiAvatarTitleConfig, AiAvatarCoverConfig, VoiceEmotion } from "../types"
/* ── 情绪:中文 → 英文(防御性映射;state 默认已是英文) ── */
const EMOTION_ZH_TO_EN: Record<string, VoiceEmotion> = {
: "natural",
: "excited",
: "calm",
: "friendly",
}
const VALID_EMOTIONS: VoiceEmotion[] = ["natural", "excited", "calm", "friendly"]
/** 归一化为后端英文枚举 natural/excited/calm/friendly;非法/空值回退 natural。 */
export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
if (!raw) return "natural"
const v = raw.trim()
if ((VALID_EMOTIONS as string[]).includes(v)) return v as VoiceEmotion
return EMOTION_ZH_TO_EN[v] ?? "natural"
}
/* ── 标题:前端 state → 后端 build_title_drawtext_filter 字段(单个 title_config dict ── */
/**
* 后端真实字段:text(或content)、font(或font_preset)、font_size(或size)、
* font_color(或color,可传 #RRGGBB)、position(top/center/bottom/custom)、
* enabled、bold、stroke{enabled,width,color}、shadow{enabled,color,offset_x,offset_y}、
* pos_x/pos_y(custom 时)。
* 口播标题默认 position=bottom(不传后端会默认 top 跑到画面顶部)。
*/
export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string, unknown> {
const text = (cfg.title || "").trim()
if (!text) return {}
const position = cfg.position || "bottom"
const payload: Record<string, unknown> = {
text,
enabled: true,
font: cfg.font || "思源黑体",
font_size: Math.round(cfg.size) || 36,
font_color: cfg.color || "#ffffff",
position,
bold: !!cfg.bold,
stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false },
shadow: cfg.shadow
? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 }
: { enabled: false },
}
// 自定义坐标(custom 位置)
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
payload.pos_x = cfg.pos_x
payload.pos_y = cfg.pos_y
}
return payload
}
/* ── 封面:前端 state → 后端 render cover_config ── */
export function buildCoverConfigPayload(
cfg: AiAvatarCoverConfig,
smartCoverUrl: string | null,
): Record<string, unknown> {
const payload: Record<string, unknown> = {
enabled: !!cfg.enabled,
mode: cfg.mode,
// build_cover_extract_command 读取 timestamp(截帧秒数)
timestamp: cfg.frame_time || 0,
}
if (smartCoverUrl) payload.cover_url = smartCoverUrl
// 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用
if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) {
payload.upload_url = cfg.upload_url
}
return payload
}
@@ -0,0 +1,62 @@
/**
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
*/
export interface ScriptSentence {
/** 句子序号(从 0 开始,对应提交给后端的 script_segment_index */
index: number
/** 句子文本(去掉首尾空白) */
text: string
/** 句子字数(按中文/字符计,去除空白) */
charCount: number
/** 累计起始字数(用于时间估算) */
startChar: number
/** 估算的对口型视频内起始时间(秒) */
startTime: number
/** 估算的对口型视频内结束时间(秒) */
endTime: number
}
/**
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
*/
export function splitScriptIntoSentences(
scriptText: string,
outputDuration: number,
): ScriptSentence[] {
const text = (scriptText || "").trim()
if (!text) return []
const rawParts = text
.split(/[。!?!?;\n\r]+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
const totalChars = rawParts.reduce((sum, part) => sum + part.replace(/\s/g, "").length, 0)
const duration = outputDuration > 0 ? outputDuration : 0
const sentences: ScriptSentence[] = []
let accChar = 0
rawParts.forEach((part, i) => {
const charCount = part.replace(/\s/g, "").length
const startTime = duration > 0 && totalChars > 0 ? (accChar / totalChars) * duration : 0
const endTime =
duration > 0 && totalChars > 0 ? ((accChar + charCount) / totalChars) * duration : 0
sentences.push({
index: i,
text: part,
charCount,
startChar: accChar,
startTime: round1(startTime),
endTime: round1(endTime),
})
accChar += charCount
})
return sentences
}
function round1(n: number): number {
return Math.round(n * 10) / 10
}
@@ -1,4 +0,0 @@
export { useBatchDelete } from "./useBatchDelete"
export { useBatchTag } from "./useBatchTag"
export { useBatchClassify } from "./useBatchClassify"
export { useBatchMark } from "./useBatchMark"
@@ -1,58 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
@@ -1,40 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
@@ -1,53 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "../constants"
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -1,86 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
@@ -1,8 +0,0 @@
/**
* @deprecated 请从 ./batch/ 目录导入子模块
* 保持向后兼容,re-export 所有批量操作 Hook
*/
export { useBatchDelete } from "./batch/useBatchDelete"
export { useBatchTag } from "./batch/useBatchTag"
export { useBatchClassify } from "./batch/useBatchClassify"
export { useBatchMark } from "./batch/useBatchMark"
@@ -10,7 +10,7 @@
============================================================ */
.dup-page {
padding: var(--space-2xl) var(--space-lg);
max-width: 1100px;
max-width: 1400px;
margin: 0 auto;
}
@@ -1,5 +0,0 @@
/**
* LayerConfig 入口(向后兼容)
* 实际实现位于 ./layer-config/ 目录
*/
export { default } from "./layer-config"
@@ -1,65 +0,0 @@
/**
* 混剪图层列表
*/
import React from "react"
import type { PipLayer } from "@/pages/editing-planner/types"
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerListProps {
layers: PipLayer[]
selectedId: string
onSelect: (id: string) => void
onAdd: () => void
onDelete: (id: string) => void
}
const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onAdd, onDelete }) => {
return (
<div className="pip-layer-list">
<div className="pip-toolbar" style={{ marginBottom: 8 }}>
<button className="pip-add-btn" onClick={onAdd}>
+
</button>
</div>
{layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => onSelect(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
)
}
export default LayerList
@@ -1,172 +0,0 @@
import React from "react"
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { GRID_POSITIONS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerPositionSizeProps {
layer: PipLayer
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
/**
* 图层位置与尺寸配置面板
*/
export const LayerPositionSize: React.FC<LayerPositionSizeProps> = ({
layer,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => (
<>
{/* 素材类型 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* 素材 URL */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* 位置:九宫格 + 坐标 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* 尺寸 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* 圆角 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* 透明度 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
</>
)
@@ -1,87 +0,0 @@
import React from "react"
import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
import { ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerTimingAnimationProps {
layer: PipLayer
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
}
/**
* 图层时间与动画配置面板
*/
export const LayerTimingAnimation: React.FC<LayerTimingAnimationProps> = ({
layer,
totalDuration,
onUpdate,
}) => (
<>
{/* 时间 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* 入场动画 */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)
@@ -1,33 +0,0 @@
import React from "react"
import type { PipLayer } from "@/pages/editing-planner/types"
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
interface PipPreviewProps {
layers: PipLayer[]
selectedId: string
}
/**
* PIP 图层迷你预览组件
*/
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => (
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${selectedId === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
)
@@ -1,57 +0,0 @@
/**
* 混剪单图层配置区
*/
import React from "react"
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { PipPreview } from "./PipPreview"
import { LayerPositionSize } from "./LayerPositionSize"
import { LayerTimingAnimation } from "./LayerTimingAnimation"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* 迷你预览 */}
<PipPreview layers={layers} selectedId={layer.id} />
{/* 位置与尺寸 */}
<LayerPositionSize
layer={layer}
onUpdate={onUpdate}
onGridClick={onGridClick}
onWidthChange={onWidthChange}
onHeightChange={onHeightChange}
/>
{/* 时间与动画 */}
<LayerTimingAnimation layer={layer} totalDuration={totalDuration} onUpdate={onUpdate} />
</div>
)
}
export default LayerConfig
@@ -1,142 +0,0 @@
/**
* 贴纸素材库(emoji / 图片 / 文字花字)
*/
import React, { useState } from "react"
import type { StickerType, TextStickerPreset } from "@/pages/editing-planner/types"
import {
EMOJI_LIST,
STICKER_TYPE_TABS,
TEXT_PRESET_STYLES,
TEXT_STICKER_PRESET_LABELS,
} from "@/pages/editing-planner/constants/sticker"
interface StickerLibraryProps {
activeTab: StickerType
onTabChange: (tab: StickerType) => void
onAddSticker: (type: StickerType, content: string) => void
}
const StickerLibrary: React.FC<StickerLibraryProps> = ({
activeTab,
onTabChange,
onAddSticker,
}) => {
const [textInput, setTextInput] = useState("")
const imageInputRef = React.useRef<HTMLInputElement>(null)
const handleAddImage = () => {
const val = imageInputRef.current?.value.trim()
if (val) {
onAddSticker("image", val)
if (imageInputRef.current) imageInputRef.current.value = ""
}
}
const handleAddText = () => {
if (textInput.trim()) {
onAddSticker("text", textInput.trim())
setTextInput("")
}
}
return (
<>
{/* 类型 Tab */}
<div className="sticker-tabs">
{STICKER_TYPE_TABS.map((t) => (
<button
key={t.value}
className={`sticker-tab${activeTab === t.value ? " active" : ""}`}
onClick={() => onTabChange(t.value)}
>
{t.label}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => onAddSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
ref={imageInputRef}
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
handleAddImage()
}
}}
/>
<button className="sticker-url-add-btn" onClick={handleAddImage}>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={handleAddText}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
)
}
export default StickerLibrary
@@ -1,54 +0,0 @@
/**
* 已添加贴纸列表
*/
import React from "react"
import type { StickerItem } from "@/pages/editing-planner/types"
interface StickerListProps {
items: StickerItem[]
selectedId: string | null
onSelect: (id: string) => void
onDelete: (id: string) => void
}
const StickerList: React.FC<StickerListProps> = ({ items, selectedId, onSelect, onDelete }) => {
if (items.length === 0) return null
const getDisplayContent = (item: StickerItem) => {
if (item.type === "emoji") return { icon: item.content, name: "表情贴纸" }
if (item.type === "text") return { icon: "T", name: item.content.slice(0, 10) }
return { icon: "🖼", name: "图片贴纸" }
}
return (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({items.length})</div>
<div className="sticker-list">
{items.map((item) => {
const { icon, name } = getDisplayContent(item)
return (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="sticker-list-icon">{icon}</span>
<span className="sticker-list-name">{name}</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(item.id)
}}
>
</button>
</div>
)
})}
</div>
</div>
)
}
export default StickerList
@@ -1,39 +0,0 @@
import React from "react"
import type { StickerItem } from "@/pages/editing-planner/types"
import { TEXT_PRESET_STYLES } from "@/pages/editing-planner/constants/sticker"
interface StickerPreviewProps {
sticker: StickerItem
}
export const StickerPreview: React.FC<StickerPreviewProps> = ({ sticker }) => (
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
width: `${sticker.width}%`,
height: `${sticker.height}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
opacity: sticker.opacity / 100,
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[sticker.text_preset],
}}
>
{sticker.type === "emoji" && sticker.content}
{sticker.type === "text" && sticker.content}
{sticker.type === "image" && (
<img
src={sticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
)
@@ -1,130 +0,0 @@
/**
* 选中贴纸的属性编辑器
*/
import React from "react"
import type { StickerItem } from "@/pages/editing-planner/types"
import { StickerPreview } from "./StickerPreview"
import { TextStickerPropsEditor } from "./TextStickerPropsEditor"
interface StickerPropsEditorProps {
sticker: StickerItem
totalDuration: number
onUpdate: (id: string, partial: Partial<StickerItem>) => void
}
const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
sticker,
totalDuration,
onUpdate,
}) => {
return (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.x}
onChange={(e) => onUpdate(sticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.y}
onChange={(e) => onUpdate(sticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={sticker.width}
onChange={(e) =>
onUpdate(sticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{sticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={sticker.rotation}
onChange={(e) => onUpdate(sticker.id, { rotation: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.opacity}
onChange={(e) => onUpdate(sticker.id, { opacity: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.start_time}
onChange={(e) => onUpdate(sticker.id, { start_time: Number(e.target.value) })}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.duration}
onChange={(e) => onUpdate(sticker.id, { duration: Number(e.target.value) })}
/>
</div>
{/* 文字贴纸特有属性 */}
<TextStickerPropsEditor sticker={sticker} onUpdate={onUpdate} />
{/* 预览 */}
<StickerPreview sticker={sticker} />
</div>
)
}
export default StickerPropsEditor
@@ -1,57 +0,0 @@
import React from "react"
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/constants/sticker"
interface TextStickerPropsEditorProps {
sticker: StickerItem
onUpdate: (id: string, partial: Partial<StickerItem>) => void
}
export const TextStickerPropsEditor: React.FC<TextStickerPropsEditorProps> = ({
sticker,
onUpdate,
}) => {
if (sticker.type !== "text") return null
return (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={sticker.text_preset}
onChange={(e) =>
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={sticker.font_size}
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={sticker.text_color}
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
/>
</div>
</>
)
}
@@ -1,62 +0,0 @@
/**
* TTS 滑块组件(语速/语调/音量)
*/
import React from "react"
import { Slider } from "antd"
interface TtsSliderProps {
label: string
value: number
min: number
max: number
step: number
unit?: string
onChange: (val: number) => void
marks?: string[]
tooltipFormatter?: (v: number) => string
}
const TtsSlider: React.FC<TtsSliderProps> = ({
label,
value,
min,
max,
step,
unit = "",
onChange,
marks,
tooltipFormatter,
}) => {
const displayValue =
unit === "x"
? `${value.toFixed(2)}x`
: label === "语调"
? `${value > 0 ? "+" : ""}${value} 半音`
: `${value}${unit}`
return (
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label">{label}</span>
<span className="tts-slider-value">{displayValue}</span>
</div>
<Slider
min={min}
max={max}
step={step}
value={value}
onChange={(v) => onChange(v as number)}
tooltip={tooltipFormatter ? { formatter: (v) => tooltipFormatter(v as number) } : undefined}
/>
{marks && (
<div className="tts-slider-marks">
{marks.map((m, i) => (
<span key={i}>{m}</span>
))}
</div>
)}
</div>
)
}
export default TtsSlider
@@ -1,50 +0,0 @@
/**
* TTS 音色选择组件
*/
import React from "react"
import type { TTSVoice } from "@/api/tts"
import { VOICE_CATEGORY_MAP } from "../../constants/tts"
interface VoiceSelectorProps {
voices: TTSVoice[]
voicesLoading: boolean
selectedVoiceId: string
onVoiceSelect: (voiceId: string) => void
}
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
voices,
voicesLoading,
selectedVoiceId,
onVoiceSelect,
}) => {
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
return (
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && selectedVoiceId === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && onVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
)
}
export default VoiceSelector
+12 -1
View File
@@ -548,10 +548,21 @@ const GeneratePage: React.FC = () => {
<div
style={{
display: "flex",
justifyContent: "center",
flexDirection: "column",
alignItems: "center",
marginTop: 16,
}}
>
<h2
style={{
textAlign: "center",
marginBottom: 12,
fontSize: "1.5rem",
fontWeight: 600,
}}
>
🎬
</h2>
<div
style={{
display: "flex",
@@ -0,0 +1,192 @@
/* ============================================================
TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦)
从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」
两个页面共用。AI数字人页面不引入 generate.css,直接由
TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、
配色描边、选中态与智能剪辑页面完全一致。
注意:本文件规则与 generate.css 中同名规则一一对应、取值相同;
智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。
============================================================ */
/* ── 区块容器 ── */
.xx-title-style-section {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid var(--border-light);
}
.xx-section-subtitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
.xx-title-style-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.xx-half-field {
margin-bottom: 0;
}
.xx-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.xx-field-label-row label {
margin-bottom: 0;
}
.xx-field-value {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
}
/* ── 共用表单字段(位置/字体下拉) ── */
.xx-title-style-section .xx-form-field {
margin-bottom: 14px;
}
.xx-title-style-section .xx-form-field:last-child {
margin-bottom: 0;
}
.xx-title-style-section .xx-form-field label {
display: block;
font-weight: 600;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select,
.xx-title-style-section .xx-form-field input {
width: 100%;
height: 44px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 14px;
font-size: 14px;
outline: 0;
transition: 0.15s ease;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select:focus,
.xx-title-style-section .xx-form-field input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* ── 字号滑块 ── */
.xx-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.xx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
.xx-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
/* ── 标题预设卡片网格(24 个 T 格子) ── */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(6, 52px);
gap: 1px;
}
.xx-title-preset-card {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: #404040;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.xx-title-preset-card:hover {
border-color: #666;
background: #4d4d4d;
}
.xx-title-preset-card.active {
border-color: #409eff;
background: #4d4d4d;
}
.xx-title-preset-preview-text {
font-size: 32px;
line-height: 1;
user-select: none;
}
/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */
.xx-style-btns {
display: flex;
gap: 8px;
}
.xx-style-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
cursor: pointer;
font-size: 15px;
color: var(--text-secondary);
transition: all 0.15s;
}
.xx-style-btn:hover {
border-color: var(--primary-300);
color: var(--primary-color);
}
.xx-style-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
@@ -5,6 +5,9 @@
import React from "react"
import type { TitleSettings } from "../../types"
import TitlePresetsGrid from "./TitlePresetsGrid"
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
import "./TitleStylePanel.css"
interface PositionOption {
value: string
+1 -1
View File
@@ -10,7 +10,7 @@
.xx-generate-page {
min-height: 100%;
padding: var(--space-xl);
max-width: 1400px;
max-width: 1680px;
margin: 0 auto;
}
@@ -5,7 +5,7 @@
.mt-page {
padding: var(--space-xl);
max-width: 1400px;
max-width: 1680px;
margin: 0 auto;
}
@@ -0,0 +1,273 @@
/**
* 文案库页面 — Issue #1811
* 风格对齐标题库(同类资源管理页面统一风格),单列卡片列表
* 功能:列表 / 新建 / 编辑 / 删除 / 按标题搜索 / 空状态
* 对接后端 /api/v1/scripts CRUD
*/
import React, { useEffect, useMemo, useState } from "react"
import { Modal, message, Empty, Button, Input, Popconfirm } from "antd"
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from "@ant-design/icons"
import {
getScripts,
createScript,
updateScript,
deleteScript,
type ScriptItem,
} from "@/api/scripts"
import "./scripts.css"
const { TextArea } = Input
const ScriptLibrary: React.FC = () => {
const [scripts, setScripts] = useState<ScriptItem[]>([])
const [loading, setLoading] = useState(false)
const [searchText, setSearchText] = useState("")
// 弹窗状态
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<ScriptItem | null>(null)
const [formTitle, setFormTitle] = useState("")
const [formContent, setFormContent] = useState("")
const [submitting, setSubmitting] = useState(false)
const load = async () => {
setLoading(true)
try {
const items = await getScripts()
setScripts(items)
} catch (err) {
message.error(err instanceof Error ? err.message : "加载文案列表失败")
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const filtered = useMemo(() => {
const kw = searchText.trim().toLowerCase()
if (!kw) return scripts
return scripts.filter((s) => s.title.toLowerCase().includes(kw))
}, [scripts, searchText])
const openCreate = () => {
setFormTitle("")
setFormContent("")
setCreateOpen(true)
}
const openEdit = (item: ScriptItem) => {
setEditing(item)
setFormTitle(item.title)
setFormContent(item.content)
}
const handleCloseCreate = () => {
setCreateOpen(false)
setFormTitle("")
setFormContent("")
}
const handleCloseEdit = () => {
setEditing(null)
setFormTitle("")
setFormContent("")
}
const handleCreate = async () => {
const title = formTitle.trim()
const content = formContent.trim()
if (!title || !content) {
message.warning("请填写标题和正文")
return
}
setSubmitting(true)
try {
await createScript({ title, content })
message.success("文案已创建")
handleCloseCreate()
await load()
} catch (err) {
message.error(err instanceof Error ? err.message : "创建文案失败")
} finally {
setSubmitting(false)
}
}
const handleUpdate = async () => {
if (!editing) return
const title = formTitle.trim()
const content = formContent.trim()
if (!title || !content) {
message.warning("请填写标题和正文")
return
}
setSubmitting(true)
try {
await updateScript(editing.id, { title, content })
message.success("文案已更新")
handleCloseEdit()
await load()
} catch (err) {
message.error(err instanceof Error ? err.message : "更新文案失败")
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string) => {
try {
await deleteScript(id)
message.success("文案已删除")
await load()
} catch (err) {
message.error(err instanceof Error ? err.message : "删除文案失败")
}
}
const preview = (content: string) => {
const text = content.replace(/\s+/g, " ").trim()
return text.length > 120 ? `${text.slice(0, 120)}` : text || "(空)"
}
const formatTime = (iso: string) => {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`
}
return (
<div className="xx-scripts-page">
<div className="xx-scripts-layout">
{/* 顶部操作栏 */}
<div className="xx-scripts-filters">
<div className="xx-scripts-filters-left">
<Input
prefix={<SearchOutlined />}
placeholder="按标题搜索"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 260 }}
/>
</div>
<div className="xx-scripts-filters-right">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
</div>
{/* 列表 / 空状态 */}
{loading ? (
<div className="xx-scripts-loading"></div>
) : filtered.length === 0 ? (
<Empty
description={searchText ? "没有匹配的文案" : "暂无文案,点击右上角「新建文案」开始创作"}
/>
) : (
<div className="xx-scripts-list">
{filtered.map((s) => (
<div key={s.id} className="xx-script-card">
<div className="xx-script-card-header">
<div className="xx-script-title">{s.title}</div>
<div className="xx-script-actions">
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => openEdit(s)}
>
</Button>
<Popconfirm
title="确认删除此文案?"
description="删除后不可恢复"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(s.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
</div>
<div className="xx-script-preview">{preview(s.content)}</div>
<div className="xx-script-meta">
<span>{s.char_count ?? s.content.length} </span>
<span>·</span>
<span>{formatTime(s.created_at)}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* 新建弹窗 */}
<Modal
title="新建文案"
open={createOpen}
onCancel={handleCloseCreate}
onOk={handleCreate}
confirmLoading={submitting}
destroyOnClose
okText="创建"
cancelText="取消"
>
<div className="xx-script-form">
<Input
placeholder="标题"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
maxLength={200}
/>
<TextArea
placeholder="正文"
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={8}
maxLength={5000}
/>
</div>
</Modal>
{/* 编辑弹窗 */}
<Modal
title="编辑文案"
open={!!editing}
onCancel={handleCloseEdit}
onOk={handleUpdate}
confirmLoading={submitting}
destroyOnClose
okText="保存"
cancelText="取消"
>
<div className="xx-script-form">
<Input
placeholder="标题"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
maxLength={200}
/>
<TextArea
placeholder="正文"
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={8}
maxLength={5000}
/>
</div>
</Modal>
</div>
)
}
export default ScriptLibrary
+137
View File
@@ -0,0 +1,137 @@
/**
* 文案库页面 - V21 设计系统样式
* 单列卡片列表,风格对齐标题库(xx-titles-page
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-scripts-page {
min-height: 100%;
padding: var(--space-xl);
}
.xx-scripts-layout {
display: flex;
flex-direction: column;
gap: var(--space-lg);
max-width: 960px;
margin: 0 auto;
}
/* ============================================================
顶部筛选栏
============================================================ */
.xx-scripts-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.xx-scripts-filters-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-scripts-filters-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* ============================================================
列表
============================================================ */
.xx-scripts-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.xx-scripts-loading {
text-align: center;
color: var(--text-secondary);
padding: var(--space-xl);
font-size: var(--font-size-sm);
}
/* ============================================================
文案卡片(对齐标题卡片风格,单列)
============================================================ */
.xx-script-card {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-md);
padding: var(--space-md);
transition: var(--transition-all);
display: flex;
flex-direction: column;
gap: 10px;
}
.xx-script-card:hover {
border-color: var(--primary-color);
background: var(--bg-secondary);
box-shadow: var(--shadow-sm);
}
.xx-script-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
}
.xx-script-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
line-height: 1.5;
word-break: break-word;
flex: 1;
}
.xx-script-actions {
display: flex;
align-items: center;
gap: var(--space-xxs);
flex-shrink: 0;
opacity: 0;
transition: var(--transition-opacity, opacity 0.2s);
}
.xx-script-card:hover .xx-script-actions {
opacity: 1;
}
.xx-script-preview {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
word-break: break-word;
}
.xx-script-meta {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-xs, 12px);
color: var(--text-tertiary);
}
/* ============================================================
弹窗表单
============================================================ */
.xx-script-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.xx-script-form textarea.ant-input {
resize: vertical;
}
+1 -1
View File
@@ -2,7 +2,7 @@
.task-center {
padding: var(--space-lg);
max-width: 1400px;
max-width: 1680px;
margin: 0 auto;
}
+4
View File
@@ -24,6 +24,10 @@ const appChildren: RouteObject[] = [
path: "titles",
lazy: lazyRoute(() => import("@/pages/titles/TitleLibrary")),
},
{
path: "scripts",
lazy: lazyRoute(() => import("@/pages/scripts/ScriptLibrary")),
},
{
path: "voices",
lazy: lazyRoute(() => import("@/pages/voices/VoiceLibrary")),
@@ -36,10 +36,10 @@ import "@/pages/assets/hooks/useLibraryManagement"
import "@/pages/assets/hooks/useAssetUpload"
import "@/pages/assets/hooks/useAssetSelection"
import "@/pages/assets/hooks/useAssetOperations"
import "@/pages/assets/hooks/asset-operations/batch/useBatchDelete"
import "@/pages/assets/hooks/asset-operations/batch/useBatchTag"
import "@/pages/assets/hooks/asset-operations/batch/useBatchClassify"
import "@/pages/assets/hooks/asset-operations/batch/useBatchMark"
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchDelete"
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchTag"
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchClassify"
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchMark"
describe("AssetLibrary module smoke test", () => {
it("should load all asset modules", () => {
@@ -53,13 +53,6 @@ import "@/pages/editing-planner/components/clip-properties/SubtitleSettingsSecti
import "@/pages/editing-planner/components/clip-properties/BgmSettingsSection"
import "@/pages/editing-planner/components/clip-properties/ClipDetailSection"
import "@/pages/editing-planner/components/clip-properties/StatsSection"
import "@/pages/editing-planner/components/pip-config/LayerList"
import "@/pages/editing-planner/components/pip-config/LayerConfig"
import "@/pages/editing-planner/components/sticker/StickerLibrary"
import "@/pages/editing-planner/components/sticker/StickerList"
import "@/pages/editing-planner/components/sticker/StickerPropsEditor"
import "@/pages/editing-planner/components/sticker/StickerPreview"
import "@/pages/editing-planner/components/sticker/TextStickerPropsEditor"
import "@/pages/editing-planner/components/filter/FilterPresetGrid"
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
@@ -67,8 +60,6 @@ import "@/pages/editing-planner/components/subtitle-style/SubtitlePreview"
import "@/pages/editing-planner/components/subtitle-style/SubtitleModeSwitch"
import "@/pages/editing-planner/components/subtitle-style/SubtitlePositionSelector"
import "@/pages/editing-planner/components/subtitle-style/SubtitleEffectButtons"
import "@/pages/editing-planner/components/tts/VoiceSelector"
import "@/pages/editing-planner/components/tts/TtsSlider"
import "@/pages/editing-planner/components/watermark/WatermarkTypeTabs"
import "@/pages/editing-planner/components/watermark/ImageWatermarkSection"
import "@/pages/editing-planner/components/watermark/TextWatermarkSection"
+169
View File
@@ -0,0 +1,169 @@
# AI 数字人前后端接口契约(#1797 / #1822
> 分支:`fix/ai-avatar-v3-1797`
> 范围:TTS→对口型链路打通、语速/情绪透传、封面智能选帧、标题字段对齐
> 本文档为前后端联调的唯一字段口径。
---
## 1. 对口型创建接口 `POST /api/v1/lipsync/jobs`
支持两种输入模式,**二选一**
### 模式 A(推荐):TTS 直生 —— 传音色 + 文案,后端内部合成音频
前端无需先调 TTS。后端收到请求后:先调 CosyVoice 合成音频 → 转存 OSS → 再提交 MediaKit 对口型。
```jsonc
{
"video_url": "https://oss.../person.mp4", // 必填,人物视频(MP4
"voice_id": "cosyvoice-v3-flash-99-xxxx", // 必填,音色 ID(预置音色 或 克隆 profile UUID
"script_text": "省是浙江省,市是永康市……", // 必填,要合成的文案
"speed": 1.0, // 可选,语速 0.5~2.0,默认 1.0
"emotion": "excited", // 可选,情绪,见 §3
"enable_video_loop": false, // 可选,音频长于视频时是否循环画面
"project_id": "" // 可选
}
```
### 模式 B:直接音频 —— 前端已准备好音频
```jsonc
{
"video_url": "https://oss.../person.mp4", // 必填
"audio_url": "https://oss.../voice.mp3", // 必填,mp3/aac/wav/m4a/flac
"enable_video_loop": false
}
```
### 校验与错误码
| 场景 | HTTP | detail.code |
|------|------|-------------|
| 既无 audio_url 又无 voice_id+script_text | 422 | schema 校验) |
| video_url 非 MP4 / audio_url 格式不支持 | 422 | schema 校验) |
| 克隆音色不属于当前用户 | 403 | `VoiceForbidden` |
| 克隆音色尚未合成完成 | 400 | `VoiceNotReady` |
| TTS 合成失败(如 CosyVoice 欠费) | 502 | `TTSSynthesisFailed` |
| MediaKit 提交失败 | 502 | `*`(透传 MediaKit code |
### 轮询
- `GET /api/v1/lipsync/jobs/{id}`:非终态任务先返回 DB 缓存,**后台异步刷新 MediaKit**(不会阻塞轮询)。
- `status` 流转:`pending``submitted``running`/`processing`MediaKit 中间态同步)→ `completed` / `failed`
- `completed``output_video_url` 为**已转存自家 OSS 的非临时 URL**(不会过期)。
- 前端每 3s 轮询,命中 `completed`/`failed` 即停。
---
## 2. TTS 合成接口语速/情绪透传
- `POST /api/v1/tts/synthesize`(异步任务)与 `POST /api/v1/tts/preview`(即时试听)均新增:
- `speed`float0.5~2.0,默认 1.0 → 透传 CosyVoice payload 的 `rate`
- `emotion`:string,见 §3 映射 → 透传 `emotion`
- 透传链路:`route → CreateTTSJobUseCase(metadata) → TTSJobWorkflow.start_synthesis / 分段合成 → CosyVoiceService.submit_synthesize_task(rate/emotion)`
- 分段合成(长文案)与失败重合成路径同样透传 speed/emotion。
---
## 3. 情绪枚举(前后端统一)
前端把中文选项映射成英文后传后端;后端同时接受中文/英文,非法值忽略(走默认自然)。
| 前端选项 | 传参值 | CosyVoice 枚举 |
|---------|--------|---------------|
| 自然 | `natural` | natural |
| 兴奋 | `excited` | excited |
| 沉稳 | `calm` | calm |
| 亲切 | `friendly` | friendly |
后端 `normalize_emotion()` 也接受中文(自然/兴奋/沉稳/亲切)做兜底映射。
---
## 4. 智能封面接口 `POST /api/v1/ai-avatar/render/smart-cover`
独立接口,**不依赖渲染任务**,前端「智能获取封面」按钮直接调用。
**请求**
```jsonc
{
"video_url": "https://oss.../avatar_output.mp4", // 必填,数字人视频
"max_frames": 5 // 可选,抽帧数量 1~10,默认 5
}
```
**响应**
```jsonc
{
"cover_url": "https://oss.../ai-avatar/covers/xxx/cover_yy.jpg", // OSS 非临时 URL
"status": "completed", // completed / fallback_failed
"message": "" // 失败原因
}
```
**实现**:复用智能剪辑同款能力 —— MediaKit `extract_frames(SpecifiedFrames)` 抽 5 帧 → `cover_frame_scorer.score_frames`(清晰度+亮度+色彩)评分选最佳 → 转存 OSS。
**不再使用 FFmpeg 简单首帧**。渲染管线最终封面也优先走该智能选帧,MediaKit 不可用时才回退 FFmpeg。
---
## 5. 渲染接口 `POST /api/v1/ai-avatar/render`
```jsonc
{
"lipsync_job_id": "7c29a3b2-...", // 必填,已 completed 的对口型任务
"script_id": "", // 可选!见下方说明
"b_roll_segments": [], // 可选,B-roll 片段
"title_config": { ... }, // 可选,单个标题配置 dict(见 §6)
"cover_config": { ... }, // 可选,封面配置(建议改用 smart-cover
"project_id": ""
}
```
**`script_id` 是否必填:可选。**
- 从文案库选了文案时传对应文案 ID(后端做归属校验)。
- **手动输入文案、走 TTS 直生模式时不传(留空)即可**——渲染管线不依赖文案内容,`script_id` 仅用于归属校验。留空不会卡手动文案用户。
---
## 6. 标题配置 `title_config` 字段清单(以 build_title_drawtext_filter 为准)
渲染请求收的是**单个 `title_config` dict**(不是 `titles[]` 数组),字段与 `packages/domain/video_filter_builder.py``build_title_drawtext_filter()` 完全对齐:
| 字段 | 别名 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|------|
| `text` | `content` | string | ✅ | — | 标题文字;为空或 `enabled=false` 时不渲染标题 |
| `enabled` | — | bool | ❌ | `true` | 是否启用标题;false 跳过 |
| `font` | `font_preset` | string | ❌ | 思源黑体 | 字体名(后端按名字解析字体文件) |
| `font_size` | `size` | int | ❌ | 36 | 字号(像素) |
| `font_color` | `color` | string | ❌ | `#ffffff` | 文字颜色,`#RRGGBB`;后端自动去掉 `#`,也可传 `RRGGBB` 或颜色名 |
| `position` | — | string | ❌ | `top` | 预设位置:`top`(y=50) / `center`(垂直居中) / `bottom`(底部上移50px) / `custom` |
| `pos_x` | — | int/float | ❌ | — | 自定义 X 坐标(像素),仅 `position=custom` 生效 |
| `pos_y` | — | int/float | ❌ | — | 自定义 Y 坐标(像素),仅 `position=custom` 生效 |
| `bold` | — | bool | ❌ | `true` | 粗体(Bold 字体变体,回退 borderw 模拟) |
| `stroke` | — | bool/object | ❌ | — | 描边。`true`=黑描边宽2object 见下 |
| `stroke.enabled` | — | bool | ❌ | true | 是否描边 |
| `stroke.width` | — | int | ❌ | 2 | 描边宽度 |
| `stroke.color` | — | string | ❌ | `#000000` | 描边颜色 |
| `shadow` | — | bool/object | ❌ | — | 阴影。`true`=黑色阴影偏移2pxobject 见下 |
| `shadow.enabled` | — | bool | ❌ | true | 是否阴影 |
| `shadow.color` | — | string | ❌ | `#000000` | 阴影颜色 |
| `shadow.offset_x` | — | int | ❌ | 2 | 阴影 X 偏移 |
| `shadow.offset_y` | — | int | ❌ | 2 | 阴影 Y 偏移 |
**前端注意事项**
- 标题是**整条成片一个标题**(单个 dict),不是按时间段的标题数组;没有 `start`/`end`/`frame`/`fontSize` 这些字段。
- 位置用 `position` 四档枚举;自由摆放用 `position="custom"` + `pos_x`/`pos_y`(像素坐标,非比例)。
- 颜色统一传 `#RRGGBB` 即可,后端会处理 `#`;三档预设位置下标题始终水平居中。
- `stroke`/`shadow``true` 用默认样式,或传 object 精细控制颜色/宽度/偏移。
---
## 7. 前端对接清单
1. 对口型:改用**模式 A**voice_id + script_text + speed + emotion),不要再先调 TTS 拿 audio_url。
2. 音色 ID`voice_id` 可直接传克隆音色的 profile UUID,后端会解析为 CosyVoice voice_id(与 /tts 一致)。
3. 情绪下拉:自然/兴奋/沉稳/亲切 → natural/excited/calm/friendly。
4. 封面:点「智能获取封面」→ POST `/ai-avatar/render/smart-cover`,用返回的 `cover_url`
5. 渲染:手动文案直生场景 `script_id` 留空;标题传**单个** `title_config` dict(字段见 §6)。
6. 轮询:识别 `running` 等中间态,不要只认 `submitted`
+2 -2
View File
@@ -128,9 +128,9 @@ services:
- xiaoxia-net
# 健康检查配置
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
# 注:容器内无 pgrep/ps,扫描 /proc 所有进程的 cmdline 查找 celery 进程
healthcheck:
test: ["CMD-SHELL", "for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1"]
test: ["CMD-SHELL", "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
+1 -1
View File
@@ -155,7 +155,7 @@ docker run -d \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "sh -c \"grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+1 -1
View File
@@ -116,7 +116,7 @@ docker run -d \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "sh -c \"grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+4
View File
@@ -38,6 +38,10 @@ RUN chmod +x /usr/local/bin/entrypoint-worker.sh
# 业务代码(变化最频繁,放最后)
COPY apps/worker/ /app/apps/worker/
# 健康检查:扫描所有进程的 cmdline 查找 celery 进程
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1
USER celery
WORKDIR /app/apps/worker
CMD ["/usr/local/bin/entrypoint-worker.sh"]
+9 -2
View File
@@ -684,9 +684,15 @@ class LipsyncJobModel(Base):
# 输入参数
video_url = Column(Text, nullable=False)
audio_url = Column(Text, nullable=False)
audio_url = Column(Text, nullable=True) # 直生模式(voice_id+script_text)下 TTS 合成后回填
enable_video_loop = Column(Boolean, nullable=False, default=False)
# TTS 直生字段:传音色 + 文案,由后端先合成音频再对口型
voice_id = Column(String(200), nullable=False, default="")
script_text = Column(Text, nullable=False, default="")
speed = Column(Float, nullable=False, default=1.0)
emotion = Column(String(20), nullable=False, default="")
# MediaKit 任务状态
mediakit_task_id = Column(String(200), nullable=False, default="", index=True)
status = Column(
@@ -715,7 +721,8 @@ class AiAvatarRenderJob(Base):
# 输入参数
lipsync_job_id = Column(String(36), nullable=False)
script_id = Column(String(36), nullable=False)
# 文案 ID 可选:手动输入文案(TTS 直生)场景不关联文案库条目
script_id = Column(String(36), nullable=False, default="")
b_roll_segments = Column(JSON, nullable=False, default=list)
# b_roll_segments 格式: [{"script_segment_index": 0, "asset_url": "...", "mode": "fullscreen|pip", "start_time": 5.0, "end_time": 10.0}, ...]
title_config = Column(JSON, nullable=False, default=dict)
+50 -10
View File
@@ -25,6 +25,35 @@ from packages.shared.config import get_shared_settings
logger = logging.getLogger(__name__)
# CosyVoice 支持的情绪:中文标签 → API 英文值
EMOTION_MAP = {
"自然": "natural",
"兴奋": "excited",
"沉稳": "calm",
"亲切": "friendly",
"natural": "natural",
"excited": "excited",
"calm": "calm",
"friendly": "friendly",
}
VALID_EMOTIONS = {"natural", "excited", "calm", "friendly"}
def normalize_emotion(emotion: str) -> str:
"""将前端情绪值归一化为 CosyVoice 英文枚举。
支持中文(自然/兴奋/沉稳/亲切)和英文;非法值返回空串(不传,走默认)。
"""
if not emotion:
return ""
key = emotion.strip().lower()
mapped = EMOTION_MAP.get(emotion.strip()) or EMOTION_MAP.get(key)
if mapped and mapped in VALID_EMOTIONS:
return mapped
logger.warning("未知的 emotion 值,忽略: %r", emotion)
return ""
class CosyVoiceError(Exception):
"""CosyVoice API 调用异常。"""
@@ -431,6 +460,7 @@ class CosyVoiceService:
format: str = "",
speed: float = 1.0,
volume: int = 50,
emotion: str = "",
) -> dict:
"""提交语音合成任务(同步非流式,直接返回结果).
@@ -444,6 +474,7 @@ class CosyVoiceService:
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
speed: 语速(0.5-2.0),1.0 为正常速度
volume: 音量(0-100),默认 50
emotion: 情绪(natural/excited/calm/friendly),空串不传
Returns:
dict: {"audio_url": str, "request_id": str,
@@ -463,17 +494,20 @@ class CosyVoiceService:
settings = get_shared_settings()
payload = {
"model": self._model,
"input": {
"text": text,
"voice": voice_id,
"format": format or settings.cosyvoice_format,
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
"rate": speed,
"volume": volume,
},
input_payload: dict[str, Any] = {
"text": text,
"voice": voice_id,
"format": format or settings.cosyvoice_format,
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
"rate": speed,
"volume": volume,
}
# 情绪:归一化(中文→英文)后透传;空/非法则不传,走 CosyVoice 默认
norm_emotion = normalize_emotion(emotion)
if norm_emotion:
input_payload["emotion"] = norm_emotion
payload = {"model": self._model, "input": input_payload}
response = self._call_api(
method="POST",
@@ -490,6 +524,10 @@ class CosyVoiceService:
if not audio_url:
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
# DashScope 返回 http://,统一升级为 https://
if audio_url.startswith("http://"):
audio_url = audio_url.replace("http://", "https://", 1)
return {
"task_id": "", # 同步接口无 task_id,兼容旧接口
"audio_url": audio_url,
@@ -517,6 +555,7 @@ class CosyVoiceService:
format: str = "",
speed: float = 1.0,
volume: int = 50,
emotion: str = "",
timeout: float = 120.0,
) -> SynthesizeResult:
"""语音合成(同步非流式).
@@ -548,6 +587,7 @@ class CosyVoiceService:
format=format,
speed=speed,
volume=volume,
emotion=emotion,
)
return SynthesizeResult(
+14
View File
@@ -143,11 +143,16 @@ class TTSWorkflowService:
return self._start_segment_synthesis(job)
try:
_meta = dict(job.metadata)
_speed = float(_meta.get("speed", 1.0) or 1.0)
_emotion = str(_meta.get("emotion", "") or "")
submit_result = self.cosyvoice_service.submit_synthesize_task(
text=job.input_text,
voice_id=job.voice_id,
sample_rate=job.sample_rate,
format=job.format,
speed=_speed,
emotion=_emotion,
)
# 保存 task_id / request_id 到 metadata
@@ -283,6 +288,7 @@ class TTSWorkflowService:
job_metadata = job.metadata or {}
speed = float(job_metadata.get("speed", 1.0))
volume = int(job_metadata.get("volume", 50))
emotion = str(job_metadata.get("emotion", "") or "")
result = self.cosyvoice_service.submit_synthesize_task(
text=job.input_text,
@@ -291,6 +297,7 @@ class TTSWorkflowService:
format=job.format,
speed=speed,
volume=volume,
emotion=emotion,
)
audio_url = result.get("audio_url", "")
if not audio_url:
@@ -401,6 +408,9 @@ class TTSWorkflowService:
"""
max_workers = min(len(segments), _MAX_SEGMENT_WORKERS)
results: list[dict | None] = [None] * len(segments)
_seg_meta = job.metadata or {}
_seg_speed = float(_seg_meta.get("speed", 1.0) or 1.0)
_seg_emotion = str(_seg_meta.get("emotion", "") or "")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_idx = {}
@@ -411,6 +421,8 @@ class TTSWorkflowService:
voice_id=job.voice_id,
sample_rate=job.sample_rate,
format=job.format,
speed=_seg_speed,
emotion=_seg_emotion,
)
future_to_idx[future] = idx
@@ -500,6 +512,7 @@ class TTSWorkflowService:
job_metadata = job.metadata or {}
speed = float(job_metadata.get("speed", 1.0))
volume = int(job_metadata.get("volume", 50))
emotion = str(job_metadata.get("emotion", "") or "")
# 分段文本(用于缺失段重新合成)
segments = split_text(job.input_text, max_chars=_SEGMENT_THRESHOLD)
@@ -534,6 +547,7 @@ class TTSWorkflowService:
format=job.format,
speed=speed,
volume=volume,
emotion=emotion,
)
future_to_idx[future] = idx
+2 -2
View File
@@ -192,7 +192,7 @@ rollback() {
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
@@ -501,7 +501,7 @@ docker run -d \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
@@ -0,0 +1,365 @@
"""#1822 情绪/语速透传 + 对口型 TTS 直生 + 智能封面 单元测试.
CI 增量映射:
cosyvoice_service.normalize_emotion / payload emotion
lipsync_service TTS 直生分支(voice_id+script_text
ai_avatar_cover_service 智能选帧
"""
import os
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
# ── 情绪归一化 ──────────────────────────────────────────────────────────
def test_normalize_emotion_english_values():
from packages.application.cosyvoice_service import normalize_emotion
assert normalize_emotion("natural") == "natural"
assert normalize_emotion("excited") == "excited"
assert normalize_emotion("calm") == "calm"
assert normalize_emotion("friendly") == "friendly"
# 大小写 / 空白容错
assert normalize_emotion(" Excited ") == "excited"
def test_normalize_emotion_chinese_values():
from packages.application.cosyvoice_service import normalize_emotion
assert normalize_emotion("自然") == "natural"
assert normalize_emotion("兴奋") == "excited"
assert normalize_emotion("沉稳") == "calm"
assert normalize_emotion("亲切") == "friendly"
def test_normalize_emotion_invalid_returns_empty():
from packages.application.cosyvoice_service import normalize_emotion
assert normalize_emotion("") == ""
assert normalize_emotion("angry") == ""
assert normalize_emotion("喜怒哀乐") == ""
# ── CosyVoice payload 携带 emotion + rate ──────────────────────────────
def _make_service_with_captured_client(captured: dict):
"""构造 CosyVoiceService,拦截 post 请求体到 captured['json']."""
import httpx as _httpx
from packages.application import cosyvoice_service as mod
mock_client = MagicMock(spec=_httpx.Client)
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"request_id": "req-1",
"output": {"audio": {"url": "https://tts/a.mp3", "duration": 1.0}},
}
resp.raise_for_status = MagicMock()
def fake_request(method, url, headers, json, timeout):
captured["json"] = json
return resp
mock_client.request.side_effect = fake_request
with patch.object(mod, "get_shared_settings") as settings_patch:
s = MagicMock()
s.cosyvoice_api_key = "sk-test"
s.cosyvoice_base_url = "https://x/api/v1"
s.cosyvoice_model = "cosyvoice-v3-flash"
s.cosyvoice_clone_model = "voice-enrollment"
s.cosyvoice_format = "mp3"
s.cosyvoice_sample_rate = 22050
s.cosyvoice_voice = "longxiaochun"
settings_patch.return_value = s
svc = mod.CosyVoiceService(http_client=mock_client)
return svc
def test_submit_synthesize_payload_includes_emotion_and_rate():
captured: dict = {}
svc = _make_service_with_captured_client(captured)
svc.submit_synthesize_task(text="你好", voice_id="v-1", speed=1.5, emotion="兴奋")
inp = captured["json"]["input"]
assert inp["emotion"] == "excited"
assert inp["rate"] == 1.5
def test_submit_synthesize_payload_omits_emotion_when_empty():
captured: dict = {}
svc = _make_service_with_captured_client(captured)
svc.submit_synthesize_task(text="你好", voice_id="v-1")
assert "emotion" not in captured["json"]["input"]
# ── 对口型 TTS 直生分支 ─────────────────────────────────────────────────
def _lipsync_service_with_mocks():
from app.services.lipsync_service import LipsyncService
db = MagicMock()
client = MagicMock()
client.is_available = True
client.submit_lipsync.return_value = {
"success": True,
"task_id": "mk-1",
"request_id": "req-1",
}
cosy = MagicMock()
cosy.submit_synthesize_task.return_value = {
"audio_url": "https://tts/raw.mp3",
"request_id": "tts-req",
"audio_duration": 3.0,
}
svc = LipsyncService(db, client=client, cosyvoice_service=cosy, voice_clone_repo=MagicMock())
# _resolve_voice_id 默认原样返回(repo.get 返回 None
svc._voice_clone_repo.get.return_value = None
return svc, client, cosy
def test_create_job_tts_direct_mode_synthesizes_audio():
svc, client, cosy = _lipsync_service_with_mocks()
with (
patch("app.services.lipsync_service.get_shared_storage_service") as storage_patch,
patch("app.services.lipsync_service.safe_download_bytes") as dl_patch,
):
storage = MagicMock()
storage.upload_file.return_value = "https://oss/tts.mp3"
storage_patch.return_value = storage
dl_patch.return_value = b"FAKEAUDIO"
job = svc.create_job(
user_id="user-1",
video_url="https://oss/person.mp4",
voice_id="cosy-v1",
script_text="你好世界",
speed=1.2,
emotion="兴奋",
)
# 调了 TTS 合成,带 speed/emotion
cosy.submit_synthesize_task.assert_called_once()
_, kwargs = cosy.submit_synthesize_task.call_args
assert kwargs["speed"] == 1.2
assert kwargs["emotion"] == "excited"
assert kwargs["voice_id"] == "cosy-v1"
# MediaKit 用合成后的 OSS 音频 URL 提交
_, submit_kwargs = client.submit_lipsync.call_args
assert submit_kwargs["audio_url"] == "https://oss/tts.mp3"
assert submit_kwargs["video_url"] == "https://oss/person.mp4"
# DB 记录了 TTS 字段
assert job.emotion == "excited"
assert job.speed == 1.2
def test_create_job_direct_audio_mode_skips_tts():
svc, client, cosy = _lipsync_service_with_mocks()
job = svc.create_job(
user_id="user-1",
video_url="https://oss/person.mp4",
audio_url="https://oss/ready.mp3",
)
cosy.submit_synthesize_task.assert_not_called()
_, submit_kwargs = client.submit_lipsync.call_args
assert submit_kwargs["audio_url"] == "https://oss/ready.mp3"
def test_create_job_tts_failure_raises():
from app.services.mediakit_client import MediaKitError
from packages.application.cosyvoice_service import CosyVoiceError
svc, client, cosy = _lipsync_service_with_mocks()
cosy.submit_synthesize_task.side_effect = CosyVoiceError("Arrearage")
with pytest.raises(MediaKitError) as exc:
svc.create_job(
user_id="user-1",
video_url="https://oss/person.mp4",
voice_id="v-1",
script_text="文本",
)
assert exc.value.code == "TTSSynthesisFailed"
# TTS 失败不应提交 MediaKit
client.submit_lipsync.assert_not_called()
# ── refresh 同步中间状态 ────────────────────────────────────────────────
def test_refresh_syncs_running_status():
from app.services.lipsync_service import LipsyncService
db = MagicMock()
client = MagicMock()
client.get_task_status.return_value = {"success": True, "status": "running"}
svc = LipsyncService(db, client=client)
job = MagicMock()
job.status = "submitted"
job.mediakit_task_id = "mk-1"
job.id = "j-1"
svc.get_job = MagicMock(return_value=job)
result = svc.refresh_job_status("j-1", "user-1")
assert result.status == "running"
# ── 智能封面 ────────────────────────────────────────────────────────────
def test_smart_cover_selects_best_frame_and_persists():
from app.services import ai_avatar_cover_service as cov
snapshots = [
{"image_url": "https://mk/f0.jpg"},
{"image_url": "https://mk/f1.jpg"},
]
with (
patch("packages.shared.mediakit_client.get_mediakit_client") as mk_patch,
patch("packages.shared.cover_frame_scorer.score_frames") as score_patch,
patch("httpx.Client") as http_client_cls,
patch("packages.shared.storage.get_shared_storage_service") as storage_patch,
):
mk = MagicMock()
mk.is_available = True
mk.extract_frames.return_value = snapshots
mk_patch.return_value = mk
# score_frames 把 f1 选为最佳
score_patch.side_effect = lambda cands: [
{"url": "https://mk/f1.jpg", "score": 90.0, "image_path": cands[1]["image_path"]},
{"url": "https://mk/f0.jpg", "score": 60.0, "image_path": cands[0]["image_path"]},
]
resp = MagicMock()
resp.content = b"IMGDATA"
resp.raise_for_status = MagicMock()
client_instance = MagicMock()
client_instance.get.return_value = resp
client_instance.__enter__ = MagicMock(return_value=client_instance)
client_instance.__exit__ = MagicMock(return_value=False)
http_client_cls.return_value = client_instance
storage = MagicMock()
storage.public_url = "https://other-oss.example.com" # 不同host,不触发重签
storage.upload_file.return_value = "https://oss/cover.jpg"
storage_patch.return_value = storage
url = cov.generate_smart_cover("https://oss/avatar.mp4", job_id="job-1")
assert url == "https://oss/cover.jpg"
mk.extract_frames.assert_called_once()
score_patch.assert_called_once()
def test_smart_cover_returns_empty_when_mediakit_unavailable():
from app.services import ai_avatar_cover_service as cov
with (
patch("packages.shared.mediakit_client.get_mediakit_client") as mk_patch,
patch("packages.shared.storage.get_shared_storage_service") as storage_patch,
):
mk = MagicMock()
mk.is_available = False
mk_patch.return_value = mk
storage = MagicMock()
storage.public_url = "https://other-oss.example.com"
storage_patch.return_value = storage
url = cov.generate_smart_cover("https://oss/avatar.mp4")
assert url == ""
def test_sign_video_url_resigns_own_oss_url():
"""自家 OSS 私有桶 URL 会被重签为长有效期预签名 URL"""
from app.services import ai_avatar_cover_service as cov
with patch("packages.shared.storage.get_shared_storage_service") as storage_patch:
storage = MagicMock()
storage.public_url = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com"
storage.get_download_url.return_value = (
"https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/vid.mp4?Expires=999&Signature=abc"
)
storage_patch.return_value = storage
result = cov._sign_video_url_for_mediakit("https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/vid.mp4")
assert "Signature=abc" in result
storage.get_download_url.assert_called_once()
def test_sign_video_url_skips_external_url():
"""外部 URL 不会被重签"""
from app.services import ai_avatar_cover_service as cov
with patch("packages.shared.storage.get_shared_storage_service") as storage_patch:
storage = MagicMock()
storage.public_url = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com"
storage_patch.return_value = storage
result = cov._sign_video_url_for_mediakit("https://cdn.example.com/video.mp4")
assert result == "https://cdn.example.com/video.mp4"
storage.get_download_url.assert_not_called()
def test_extract_frames_uses_extended_poll_params():
"""验证抽帧使用了增大后的轮询参数"""
from app.services import ai_avatar_cover_service as cov
with (
patch("packages.shared.mediakit_client.get_mediakit_client") as mk_patch,
patch("packages.shared.storage.get_shared_storage_service") as storage_patch,
):
mk = MagicMock()
mk.is_available = True
mk.extract_frames.return_value = [{"image_url": "https://mk/f0.jpg"}]
mk_patch.return_value = mk
storage = MagicMock()
storage.public_url = "https://other-oss.example.com"
storage_patch.return_value = storage
cov.select_best_cover_frame("https://oss/video.mp4", max_frames=3)
call_kwargs = mk.extract_frames.call_args.kwargs
assert call_kwargs["poll_interval"] == 3.0
assert call_kwargs["max_poll_attempts"] == 20
assert call_kwargs["max_retries"] == 1
# ── 渲染 script_id 可选(手动文案直生场景)──────────────────────────────
def test_render_request_script_id_optional():
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
# 手动文案直生:不传 script_id 也合法
req = CreateAiAvatarRenderRequest(lipsync_job_id="j-1")
assert req.script_id == ""
# 空白被 strip
req2 = CreateAiAvatarRenderRequest(lipsync_job_id="j-1", script_id=" ")
assert req2.script_id == ""
# title_config 是单个 dict
req3 = CreateAiAvatarRenderRequest(
lipsync_job_id="j-1",
title_config={"text": "标题", "position": "top", "font_size": 40},
)
assert req3.title_config["position"] == "top"
+14 -4
View File
@@ -176,13 +176,23 @@ class TestSchemaValidation:
script_id="script-1",
)
def test_create_request_empty_script_id(self):
def test_create_request_empty_script_id_normalized(self):
"""script_id 改为可选(TTS 直生场景):空白值应规范化为空串而非抛错。"""
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
with pytest.raises(ValueError, match="script_id 不能为空"):
req = CreateAiAvatarRenderRequest(
lipsync_job_id="lipsync-1",
script_id=" ",
)
assert req.script_id == ""
def test_create_request_empty_lipsync_job_id_raises(self):
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
with pytest.raises(ValueError, match="lipsync_job_id 不能为空"):
CreateAiAvatarRenderRequest(
lipsync_job_id="lipsync-1",
script_id=" ",
lipsync_job_id=" ",
script_id="script-1",
)
+344 -56
View File
@@ -1,4 +1,4 @@
"""对口型 API 路由 + Service 单元测试 — #1796.
"""对口型 API 路由 + Service 单元测试 — #1796, #1809 参数调整.
CI 增量映射: lipsync.py (route) + lipsync_service.py test_lipsync_routes.py
"""
@@ -33,6 +33,19 @@ def mock_mediakit():
return client
@pytest.fixture
def mock_cosyvoice():
"""Mock CosyVoice 服务(v3: service 内部走 submit_synthesize_task,返回 dict."""
service = MagicMock()
service.submit_synthesize_task.return_value = {
"audio_url": "https://oss.example.com/tts-output.mp3",
"request_id": "tts-req-789",
}
# synthesize_speech 保留给直接同步调用场景
service.synthesize_speech.return_value = MagicMock(audio_url="https://oss.example.com/tts-output.mp3")
return service
def _make_mock_job(
job_id="job-1",
user_id="user-1",
@@ -48,7 +61,7 @@ def _make_mock_job(
m.user_id = user_id
m.project_id = ""
m.video_url = "https://example.com/video.mp4"
m.audio_url = "https://example.com/audio.mp3"
m.audio_url = "https://oss.example.com/tts-output.mp3"
m.enable_video_loop = False
m.mediakit_task_id = mediakit_task_id
m.status = status
@@ -64,16 +77,19 @@ def _make_mock_job(
class TestSchemaValidation:
"""Schema 验证测试."""
"""Schema 验证测试 — #1809 新参数结构."""
def test_valid_video_url(self):
def test_valid_request(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
voice_id="longxiaochun_v3",
script_text="大家好,欢迎来到直播间",
)
assert req.video_url == "https://example.com/video.mp4"
assert req.voice_id == "longxiaochun_v3"
assert req.script_text == "大家好,欢迎来到直播间"
def test_invalid_video_url_not_mp4(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
@@ -81,7 +97,8 @@ class TestSchemaValidation:
with pytest.raises(ValueError, match="MP4"):
CreateLipsyncJobRequest(
video_url="https://example.com/video.mov",
audio_url="https://example.com/audio.mp3",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
def test_invalid_video_url_empty(self):
@@ -90,7 +107,8 @@ class TestSchemaValidation:
with pytest.raises(ValueError, match="不能为空"):
CreateLipsyncJobRequest(
video_url=" ",
audio_url="https://example.com/audio.mp3",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
def test_invalid_video_url_not_http(self):
@@ -99,26 +117,38 @@ class TestSchemaValidation:
with pytest.raises(ValueError, match="HTTP"):
CreateLipsyncJobRequest(
video_url="ftp://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
def test_valid_audio_formats(self):
def test_empty_voice_id_rejected(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
for ext in [".mp3", ".aac", ".wav", ".m4a", ".flac"]:
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url=f"https://example.com/audio{ext}",
)
assert req.audio_url.endswith(ext)
def test_invalid_audio_format(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="格式不支持"):
with pytest.raises(ValueError, match="voice_id"):
CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.ogg",
voice_id=" ",
script_text="测试文本",
)
def test_empty_script_text_rejected(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="script_text"):
CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
voice_id="longxiaochun_v3",
script_text="",
)
def test_script_text_too_long(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="5000"):
CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
voice_id="longxiaochun_v3",
script_text="x" * 5001,
)
def test_enable_video_loop_default(self):
@@ -126,7 +156,8 @@ class TestSchemaValidation:
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
assert req.enable_video_loop is False
@@ -136,53 +167,146 @@ class TestSchemaValidation:
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4?token=abc",
audio_url="https://example.com/audio.mp3?sign=xyz",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
assert "?token=" in req.video_url
def test_dual_mode_fields_present(self):
"""v3 契约: 双模式——支持直接音频 audio_url,也支持 TTS 直生 voice_id+script_text."""
from app.schemas.lipsync import CreateLipsyncJobRequest
class TestLipsyncServiceUnit:
"""Service 层单元测试(纯 mock,不依赖数据库)."""
fields = CreateLipsyncJobRequest.model_fields.keys()
# 直接音频模式
assert "audio_url" in fields
# TTS 直生模式
assert "voice_id" in fields
assert "script_text" in fields
# 语速/情绪透传
assert "speed" in fields
assert "emotion" in fields
def test_create_job_success(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
def test_direct_audio_mode_accepted(self):
"""v3 契约: 只传 audio_url(直接音频模式)也合法,无需 voice_id/script_text."""
from app.schemas.lipsync import CreateLipsyncJobRequest
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit)
# 模拟 db.add + db.flush 不报错
mock_db.add = MagicMock()
mock_db.flush = MagicMock()
mock_db.commit = MagicMock()
mock_db.refresh = MagicMock()
job = svc.create_job(
user_id="user-1",
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert req.audio_url == "https://example.com/audio.mp3"
def test_neither_mode_rejected(self):
"""v3 契约: audio_url 与 voice_id+script_text 都缺时应报错."""
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError):
CreateLipsyncJobRequest(video_url="https://example.com/video.mp4")
class TestLipsyncServiceUnit:
"""Service 层单元测试(纯 mock,不依赖数据库)— #1809 更新."""
def test_create_job_success(self, mock_mediakit, mock_cosyvoice):
"""v3: TTS 直生——service 内部 submit_synthesize_task 合成后转存 OSS,再提交 MediaKit."""
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
mock_repo = MagicMock()
mock_repo.get.return_value = None # 预置音色,原样返回 voice_id
with (
patch("app.services.lipsync_service.get_shared_storage_service") as storage_patch,
patch("app.services.lipsync_service.safe_download_bytes") as dl_patch,
):
storage_patch.return_value.upload_file.return_value = "https://my-oss/tts.mp3"
dl_patch.return_value = b"audio-bytes"
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="longxiaochun_v3",
script_text="大家好,欢迎来到直播间",
speed=1.2,
emotion="兴奋",
)
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-task-123"
mock_mediakit.submit_lipsync.assert_called_once()
# TTS 直生走 submit_synthesize_task,带语速/情绪
mock_cosyvoice.submit_synthesize_task.assert_called_once()
_, kwargs = mock_cosyvoice.submit_synthesize_task.call_args
assert kwargs["text"] == "大家好,欢迎来到直播间"
assert kwargs["voice_id"] == "longxiaochun_v3"
assert kwargs["speed"] == 1.2
assert kwargs["emotion"] == "excited" # 兴奋→excited
# job 记录透传字段
assert job.speed == 1.2
assert job.emotion == "excited"
# MediaKit 用转存后的 OSS audio_url
call_kwargs = mock_mediakit.submit_lipsync.call_args
assert call_kwargs.kwargs["audio_url"] == "https://my-oss/tts.mp3"
def test_create_job_api_failure(self, mock_mediakit):
def test_create_job_tts_failure(self, mock_mediakit):
"""v3: TTS 合成失败时,CosyVoiceError 被包装为 MediaKitError(TTSSynthesisFailed)
在建 DB 记录之前抛出不提交 MediaKit"""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
from packages.application.cosyvoice_service import CosyVoiceError
mock_cosyvoice = MagicMock()
mock_cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("Arrearage 欠费")
mock_db = MagicMock()
mock_repo = MagicMock()
mock_repo.get.return_value = None
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
with pytest.raises(MediaKitError) as exc_info:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
assert exc_info.value.code == "TTSSynthesisFailed"
# 不应提交到 MediaKit
mock_mediakit.submit_lipsync.assert_not_called()
def test_create_job_api_failure(self, mock_mediakit, mock_cosyvoice):
"""MediaKit 提交失败."""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed")
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit)
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
with pytest.raises(MediaKitError, match="API 调用失败"):
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
def test_get_job_delegates_to_db(self, mock_mediakit):
def test_get_job_delegates_to_db(self, mock_mediakit, mock_cosyvoice):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job()
@@ -193,13 +317,13 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
result = svc.get_job("job-1", "user-1")
assert result is mock_job
mock_db.query.assert_called_once()
def test_get_job_not_found(self, mock_mediakit):
def test_get_job_not_found(self, mock_mediakit, mock_cosyvoice):
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
@@ -209,11 +333,11 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
result = svc.get_job("nonexistent", "user-1")
assert result is None
def test_refresh_job_completed(self, mock_mediakit):
def test_refresh_job_completed(self, mock_mediakit, mock_cosyvoice):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="submitted")
@@ -224,14 +348,14 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
result = svc.refresh_job_status("job-1", "user-1")
assert result.status == "completed"
assert result.output_video_url == "https://output.mp4"
assert result.output_duration == 30.0
def test_refresh_job_failed(self, mock_mediakit):
def test_refresh_job_failed(self, mock_mediakit, mock_cosyvoice):
from app.services.lipsync_service import LipsyncService
mock_mediakit.get_task_status.return_value = {
@@ -251,13 +375,13 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
result = svc.refresh_job_status("job-1", "user-1")
assert result.status == "failed"
assert result.error_code == "DownloadFailed"
def test_refresh_job_already_completed(self, mock_mediakit):
def test_refresh_job_already_completed(self, mock_mediakit, mock_cosyvoice):
"""已完成的任务不轮询."""
from app.services.lipsync_service import LipsyncService
@@ -269,14 +393,14 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
result = svc.refresh_job_status("job-1", "user-1")
# 不应调用 MediaKit
mock_mediakit.get_task_status.assert_not_called()
assert result.status == "completed"
def test_cancel_job_pending(self, mock_mediakit):
def test_cancel_job_pending(self, mock_mediakit, mock_cosyvoice):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="pending")
@@ -287,12 +411,12 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
def test_cancel_job_completed_not_allowed(self, mock_mediakit):
def test_cancel_job_completed_not_allowed(self, mock_mediakit, mock_cosyvoice):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="completed")
@@ -303,8 +427,172 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
result = svc.cancel_job("job-1", "user-1")
# 已完成不可取消
assert result.status == "completed"
def test_create_job_stores_tts_audio_url(self, mock_mediakit, mock_cosyvoice):
"""v3: TTS 直生模式下 job.audio_url 为转存到自家 OSS 的永久地址."""
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
mock_repo = MagicMock()
mock_repo.get.return_value = None
with (
patch("app.services.lipsync_service.get_shared_storage_service") as storage_patch,
patch("app.services.lipsync_service.safe_download_bytes") as dl_patch,
):
storage_patch.return_value.upload_file.return_value = "https://my-oss/permanent.mp3"
dl_patch.return_value = b"audio-bytes"
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="my-clone-voice",
script_text="这是一段测试文本",
)
# job.audio_url 是转存 OSS 后的永久地址
assert job.audio_url == "https://my-oss/permanent.mp3"
def test_create_job_direct_audio_skips_tts(self, mock_mediakit, mock_cosyvoice):
"""v3: 直接音频模式(传 audio_url)不触发 TTS,原样把 audio_url 提交 MediaKit."""
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=MagicMock(),
)
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/direct-audio.mp3",
)
mock_cosyvoice.submit_synthesize_task.assert_not_called()
call_kwargs = mock_mediakit.submit_lipsync.call_args
assert call_kwargs.kwargs["audio_url"] == "https://example.com/direct-audio.mp3"
assert job.audio_url == "https://example.com/direct-audio.mp3"
class TestErrorHandling:
"""v3: 音色解析与错误码在 service 层处理,路由层做 HTTP 状态码映射."""
def test_voice_id_resolve_forbidden(self, mock_mediakit, mock_cosyvoice):
"""v3: 克隆音色属于他人时 service._resolve_voice_id 抛 VoiceForbidden(路由映射 403."""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_db = MagicMock()
mock_repo = MagicMock()
other_profile = MagicMock()
other_profile.user_id = "user-other"
other_profile.voice_id = "cv-voice-1"
mock_repo.get.return_value = other_profile
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
with pytest.raises(MediaKitError) as exc_info:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="clone-profile-id",
script_text="测试",
)
assert exc_info.value.code == "VoiceForbidden"
mock_mediakit.submit_lipsync.assert_not_called()
def test_voice_id_resolve_not_ready(self, mock_mediakit, mock_cosyvoice):
"""v3: 克隆音色尚未生成 voice_id 时抛 VoiceNotReady(路由映射 400."""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_db = MagicMock()
mock_repo = MagicMock()
profile = MagicMock()
profile.user_id = "user-1"
profile.voice_id = "" # 克隆未完成
mock_repo.get.return_value = profile
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
with pytest.raises(MediaKitError) as exc_info:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="clone-profile-id",
script_text="测试",
)
assert exc_info.value.code == "VoiceNotReady"
def test_tts_value_error_mapped_to_invalid_param(self, mock_mediakit):
"""v3: CosyVoice 抛 ValueError(参数无效)被包装为 TTSInvalidParam(路由映射 400."""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_cosyvoice = MagicMock()
mock_cosyvoice.submit_synthesize_task.side_effect = ValueError("voice_id 为空")
mock_db = MagicMock()
mock_repo = MagicMock()
mock_repo.get.return_value = None
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
with pytest.raises(MediaKitError) as exc_info:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="some-voice",
script_text="test",
)
assert exc_info.value.code == "TTSInvalidParam"
def test_missing_both_inputs_raises_invalid_input(self, mock_mediakit, mock_cosyvoice):
"""v3: 既无 audio_url 又无 voice_id+script_text 时抛 InvalidInput(路由映射 400."""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_db = MagicMock()
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=MagicMock(),
)
with pytest.raises(MediaKitError) as exc_info:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
)
assert exc_info.value.code == "InvalidInput"
mock_cosyvoice.submit_synthesize_task.assert_not_called()
mock_mediakit.submit_lipsync.assert_not_called()
+4
View File
@@ -101,6 +101,7 @@ class TestTTSPreviewEndpoint:
text="你好世界",
voice_id="longxiaochun",
speed=1.0,
emotion="",
)
def test_preview_with_speed(self):
@@ -146,6 +147,7 @@ class TestTTSPreviewEndpoint:
text="测试",
voice_id="v1",
speed=1.5,
emotion="",
)
def test_preview_cosyvoice_error_returns_502(self):
@@ -330,6 +332,7 @@ class TestTTSPreviewEndpoint:
text="克隆音色测试",
voice_id="cosyvoice_actual_voice_123",
speed=1.0,
emotion="",
)
# Verify repo was queried with the UUID
mock_clone_repo.get.assert_called_once_with("abc123-uuid-of-profile")
@@ -406,6 +409,7 @@ class TestTTSPreviewEndpoint:
text="预设音色测试",
voice_id="longxiaoxia_v3",
speed=1.0,
emotion="",
)
def test_preview_clone_voice_wrong_user_returns_403(self):