Compare commits

..

22 Commits

Author SHA1 Message Date
xiaoxia a76161ed6a docs: #1895 方案v2修订:两档简化+免费用户可用AI功能+移除月赠积分+1080p上限
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 6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 10s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
AI Code Review / AI Code Review (pull_request) Successful in 6m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 9m51s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 10m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 12m44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m15s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 18m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 31m29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 42h7m8s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 42h7m55s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 42h8m7s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 42h8m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 42h7m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 41h35m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 42h6m44s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 42h7m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 42h7m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 42h7m40s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 42h7m40s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 41h35m27s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 41h35m33s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 41h35m27s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 41h35m33s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 42h6m36s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 42h6m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 42h7m37s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 42h7m46s
2026-09-14 21:15:24 +08:00
xiaoxia 9feac537ad docs: #1895 会员+积分方案v2(两档简化版+基于真实成本定价)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m44s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
AI Code Review / AI Code Review (pull_request) Successful in 6m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m46s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 10m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 11m44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m47s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m10s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 31m9s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 42h10m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 42h41m9s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 42h41m15s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 42h41m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 42h40m50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 42h40m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 42h40m44s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 42h40m45s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 42h40m46s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 42h40m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 42h40m50s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 42h40m57s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 42h8m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 42h9m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 42h9m28s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 42h9m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 42h40m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 42h40m40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 42h40m50s
2026-09-14 20:41:47 +08:00
xiaoxia 937f751f68 Merge pull request 'fix(backend): #1901 统一title_config字段名修复渲染不一致' (#1909) from fix/1901-title-config-unify into develop
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 / Build Staging Web Image (push) Successful in 21s
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 / Frontend Unit Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 8m40s
CI/CD Pipeline / Validate - Style (push) Successful in 9m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 10m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI/CD Pipeline / Unit Tests (push) Successful in 14m57s
CI/CD Pipeline / Validate - Security (push) Successful in 29m26s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 42h36m49s
CI/CD Pipeline / Build Production Web Image (push) Failing after 42h36m54s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 43h5m49s
CI/CD Pipeline / PR Build API Image (push) Failing after 43h6m25s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 43h5m56s
CI/CD Pipeline / PR Build Web Image (push) Failing after 43h5m56s
CI/CD Pipeline / Build Production API Image (push) Failing after 42h36m25s
CI/CD Pipeline / CI Gate (push) Failing after 42h36m25s
CI/CD Pipeline / Frontend Lint (push) Failing after 43h5m51s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 43h5m57s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 42h36m25s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 43h5m20s
CI/CD Pipeline / Canary Release to Production (push) Failing after 42h36m20s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 43h5m20s
2026-09-14 20:17:19 +08:00
xiaoxia afcfcf45ee fix(frontend): #1896 删除侵权华康字体修复PreviewPlayer字体映射 (#1908)
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 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m50s
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 / 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
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 43h10m14s
CI/CD Pipeline / PR Build Web Image (push) Failing after 43h11m24s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 43h10m56s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 43h9m45s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 43h10m55s
CI/CD Pipeline / PR Build API Image (push) Failing after 43h10m55s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 43h9m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 43h10m50s
fix(frontend): #1896 删除侵权华康字体修复PreviewPlayer字体映射

问题:
1. "华康俪金黑"@font-face字体文件缺失(404)且有版权风险
2. PreviewPlayer直接用中文font_preset/font作为fontFamily,浏览器不识别

修改:
- 删除"华康俪金黑"字体选项(FONT_OPTIONS+FONT_FAMILY_MAP)和@font-face声明
- 统一 editing-planner/constants/clipProperties.ts 和 subtitleStyle.ts 从 generate/constants 重新导出 FONT_OPTIONS,避免多处维护
- PreviewPlayer.tsx 标题/字幕 fontFamily 通过 getFontFamily() 映射为浏览器可识别字体栈
- FrontendPreviewPlayer.tsx 标题 fontFamily 同样通过 getFontFamily() 映射
- TitleStylePanel/PanelTitleConfig/SubtitleStylePanel 等组件因复用统一常量自动生效
- 最终保留5个字体:思源黑体、思源宋体、苹方、微软雅黑、楷体
- PingFang 别名保留在 FONT_FAMILY_MAP 中以兼容旧数据,但不在 UI 选项中显示

Refs #1896
2026-09-14 20:12:21 +08:00
CI Bot ffb40038d3 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
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 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m28s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m3s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 25m33s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13m46s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 14m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 43h19m12s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 43h43m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 43h44m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 43h43m26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 43h43m30s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 43h44m21s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 43h18m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 43h43m52s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 43h43m30s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 43h43m52s
2026-09-14 11:38:53 +00:00
saas-backend-agent 30094bc591 fix(backend): #1901 统一title字段名修复标题渲染不一致
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m12s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 8m19s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 43h50m31s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 43h50m37s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 43h51m57s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 43h50m21s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 43h49m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 43h49m59s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 43h50m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 43h50m3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 43h50m8s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 43h50m8s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 43h49m39s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 43h49m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 43h51m28s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 43h52m50s
问题:plan.config中标题配置字段名双轨不一致——Worker sync_configs_to_plan 写 plan.config['title'](归一化 size/font/color),API writeback_edit_plan_config 写 plan.config['title_config'](不归一化),三个渲染引擎读取不一致:
- video_compose_service(FFmpeg drawtext):已读 title+fallback title_config ✅
- unified_render_service(ASS字幕):只 title 无 fallback ❌
- subtitle_render_engine(字幕渲染):只读 title_config ❌

修复:
- packages/application/generation_common.py writeback_edit_plan_config:统一写 plan.config['title'](而非 title_config),并做字段名归一化 font_size→size/font_preset→font/font_color→color,与 Worker sync_configs_to_plan 行为一致
- apps/worker/video_processing/subtitle_render_engine.py:_resolve_title_config 先读 plan.config['title'],fallback plan.config['title_config'](兼容存量老数据)
- apps/worker/video_processing/unified_render_service.py:同样先读 'title',text 为空时 fallback 'title_config'
- apps/api/app/api/routes/generation_cover.py:封面E2标题配置读取加 title_config fallback
- 单测适配:test_generation_common.py/test_writeback_edit_plan_config.py 断言改 'title' 并验证归一化
- 所有渲染路径(智能剪辑/AI数字人/封面E2)统一读 'title',老数据通过 fallback 继续渲染
2026-09-14 19:27:29 +08:00
xiaoxia 939a49d1d4 fix(frontend): #1897 声音克隆页增加情绪语速设置 (#1906)
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 11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m32s
CI/CD Pipeline / Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Validate - Style (push) Successful in 3m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m45s
CI/CD Pipeline / Validate - Security (push) Successful in 6m12s
CI/CD Pipeline / Unit Tests (push) Successful in 8m16s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 37m7s
CI/CD Pipeline / CI Gate (push) Failing after 44h4m26s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 44h12m10s
CI/CD Pipeline / Frontend Lint (push) Failing after 44h12m40s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 44h12m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 44h12m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 44h12m16s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 44h3m57s
CI/CD Pipeline / Build Production API Image (push) Failing after 44h3m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 43h33m50s
CI/CD Pipeline / Build Production Web Image (push) Failing after 44h3m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 44h11m41s
CI/CD Pipeline / Deploy Production (push) Failing after 44h3m55s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 44h11m41s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 44h12m17s
fix(frontend): #1897 声音克隆页增加情绪语速设置

- getVoiceClonePreview 增加 speed/emotion 参数透传
- 新增 VoiceClonePreviewPanel:音色选择/试听文本/SpeedControl/EmotionControl
- 复用 TTS SpeedControl 和 EmotionControl 组件
- 声音克隆页在无加载且有就绪音色时展示试听面板

Refs #1897
2026-09-14 19:10:59 +08:00
xiaoxia 8ad44ad045 fix(backend): #1897 声音克隆preview接口支持情绪语速参数 (#1907)
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 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m39s
CI/CD Pipeline / Validate - Style (push) Successful in 3m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Validate - Security (push) Successful in 6m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m30s
CI/CD Pipeline / Unit Tests (push) Successful in 8m12s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 44h18m37s
CI/CD Pipeline / Build Production API Image (push) Failing after 44h18m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 44h26m51s
CI/CD Pipeline / PR Build API Image (push) Failing after 44h26m52s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 44h26m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 44h26m23s
CI/CD Pipeline / Deploy Production (push) Failing after 44h18m4s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 44h18m8s
CI/CD Pipeline / Build Production Web Image (push) Failing after 44h18m8s
CI/CD Pipeline / Canary Release to Production (push) Failing after 44h18m4s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 44h24m15s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 44h24m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 44h26m25s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 44h24m15s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:56:52 +08:00
xiaoxia 26f3abab72 fix(backend): #1892 画面插入按句号分句 (#1905)
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 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / Validate - Style (push) Successful in 3m41s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Security (push) Successful in 8m48s
CI/CD Pipeline / Unit Tests (push) Successful in 9m33s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 44h58m30s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h6m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h8m6s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h7m38s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h7m39s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h5m53s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h5m53s
CI/CD Pipeline / CI Gate (push) Failing after 44h58m2s
CI/CD Pipeline / Deploy Production (push) Failing after 44h57m58s
CI/CD Pipeline / Build Production API Image (push) Failing after 44h58m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h7m40s
CI/CD Pipeline / Canary Release to Production (push) Failing after 44h57m58s
CI/CD Pipeline / Build Production Web Image (push) Failing after 44h58m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h7m34s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:15:38 +08:00
xiaoxia 4f377d4fd3 fix(frontend): #1898 配音库AI配音增加情绪语言选择 (#1904)
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 12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Validate - Style (push) Successful in 4m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m12s
CI/CD Pipeline / Unit Tests (push) Successful in 10m22s
CI/CD Pipeline / Validate - Security (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
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h15m46s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h17m31s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h17m8s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h18m15s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h15m18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h15m19s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h16m58s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h17m41s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:05:01 +08:00
xiaoxia 35b18e5e16 docs: #1895 会员+积分替代订阅制方案设计 (#1903)
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 3s
CI/CD Pipeline / Build Staging Web Image (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 10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m45s
CI/CD Pipeline / Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / Validate - Style (push) Successful in 4m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
CI/CD Pipeline / Unit Tests (push) Successful in 10m43s
CI/CD Pipeline / Validate - Security (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
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h29m11s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h29m47s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h29m19s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h29m19s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h28m43s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h28m43s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h29m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h29m19s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 17:53:57 +08:00
xiaoxia e9dd33e2f6 Merge pull request 'fix(frontend): #1892 画面插入按句号分句' (#1902) from fix/1892-sentence-split-no-comma into develop
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 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m17s
CI/CD Pipeline / Integration Tests (push) Successful in 4m16s
CI/CD Pipeline / Validate - Style (push) Successful in 5m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m38s
CI/CD Pipeline / Validate - Security (push) Successful in 10m41s
CI/CD Pipeline / Unit Tests (push) Successful in 12m47s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 45h30m18s
CI/CD Pipeline / Build Production Web Image (push) Failing after 45h30m20s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h39m1s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h41m26s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h40m59s
CI/CD Pipeline / Build Production API Image (push) Failing after 45h29m52s
CI/CD Pipeline / CI Gate (push) Failing after 45h29m51s
CI/CD Pipeline / Canary Release to Production (push) Failing after 45h29m50s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 45h29m51s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h40m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h43m15s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h38m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h38m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h41m4s
2026-09-14 17:39:59 +08:00
saas-frontend-agent 7f18b1b35a fix(frontend): #1892 画面插入按句号分句,去掉逗号分句
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 44s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m12s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m35s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 12m34s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 10s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 37s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 45h45m11s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 45h57m44s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 45h57m46s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 45h57m46s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 45h57m15s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 45h57m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 45h57m17s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 45h57m18s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 45h57m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 45h57m19s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 45h44m45s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 45h57m22s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 45h44m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 45h44m45s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 45h44m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 45h57m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 45h57m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 45h57m13s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 45h57m19s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 46h32m35s
2026-09-14 17:25:36 +08:00
xiaoxia 880a7106d4 fix(ci): staging deploy SSH host to internal IP for Docker runner (#1891)
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 5s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m9s
CI/CD Pipeline / Integration Tests (push) Successful in 3m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Validate - Style (push) Successful in 4m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Unit Tests (push) Successful in 9m23s
CI/CD Pipeline / Validate - Security (push) Successful in 12m33s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 47h52m31s
CI/CD Pipeline / Build Production API Image (push) Failing after 47h52m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 48h2m27s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 48h4m44s
CI/CD Pipeline / PR Build Web Image (push) Failing after 48h4m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 48h4m44s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 48h2m0s
CI/CD Pipeline / CI Gate (push) Failing after 47h52m5s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 47h52m5s
CI/CD Pipeline / Canary Release to Production (push) Failing after 47h52m4s
CI/CD Pipeline / Build Production Web Image (push) Failing after 47h52m6s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 48h2m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 48h4m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 48h4m40s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 15:18:31 +08:00
xiaoxia d478a9ffbc fix(ci): push :dev tag for watchtower + fix staging deploy host to 116 (#1889)
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 6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m23s
CI/CD Pipeline / Integration Tests (push) Successful in 4m31s
CI/CD Pipeline / Validate - Style (push) Successful in 4m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m23s
CI/CD Pipeline / Unit Tests (push) Successful in 9m50s
CI/CD Pipeline / Validate - Security (push) Successful in 13m31s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 48h27m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 48h38m53s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 48h40m31s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 48h39m25s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 48h39m25s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 48h40m30s
CI/CD Pipeline / PR Build Web Image (push) Failing after 48h40m30s
CI/CD Pipeline / Build Production Web Image (push) Failing after 48h26m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 48h26m55s
CI/CD Pipeline / CI Gate (push) Failing after 48h26m54s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 48h38m25s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 48h26m54s
CI/CD Pipeline / Canary Release to Production (push) Failing after 48h26m52s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 48h38m25s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 48h39m24s
CI/CD Pipeline / PR Build API Image (push) Failing after 48h40m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 48h40m25s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 14:42:46 +08:00
xiaoxia 628fc6c4e6 fix(ci): push :dev tag to registry for watchtower auto-update (#1888)
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 9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 56s
CI/CD Pipeline / Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Validate - Security (push) Successful in 10m34s
CI/CD Pipeline / Unit Tests (push) Successful in 12m4s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 48h45m22s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 48h55m35s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 48h57m39s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 48h56m9s
CI/CD Pipeline / PR Build Web Image (push) Failing after 48h57m12s
CI/CD Pipeline / PR Build API Image (push) Failing after 48h57m13s
CI/CD Pipeline / Deploy Production (push) Failing after 48h44m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 48h44m55s
CI/CD Pipeline / CI Gate (push) Failing after 48h44m55s
CI/CD Pipeline / Canary Release to Production (push) Failing after 48h44m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 48h44m55s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 48h55m8s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 48h55m8s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 48h56m9s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 48h56m9s
CI/CD Pipeline / Frontend Lint (push) Failing after 48h57m7s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 48h57m14s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 14:26:05 +08:00
xiaoxia f651422582 fix(deploy): 修复生产部署脚本502——worker/web容器启动失败 (#1887)
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 11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 39s
CI/CD Pipeline / Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m21s
CI/CD Pipeline / Validate - Style (push) Successful in 3m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m16s
CI/CD Pipeline / Validate - Security (push) Successful in 8m17s
CI/CD Pipeline / Unit Tests (push) Successful in 8m42s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 5m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m1s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 51h35m15s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 51h44m59s
CI/CD Pipeline / PR Build Web Image (push) Failing after 51h53m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 51h53m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 51h53m20s
CI/CD Pipeline / PR Build API Image (push) Failing after 51h53m20s
CI/CD Pipeline / Deploy Production (push) Failing after 51h44m31s
CI/CD Pipeline / Build Production Web Image (push) Failing after 51h44m33s
CI/CD Pipeline / Build Production API Image (push) Failing after 51h44m33s
CI/CD Pipeline / CI Gate (push) Failing after 51h44m33s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 51h34m50s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 51h34m50s
CI/CD Pipeline / Canary Release to Production (push) Failing after 51h26m28s
CI/CD Pipeline / Frontend Lint (push) Failing after 51h53m16s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 11:29:58 +08:00
xiaoxia 14580f536a refactor(backend): 智能剪辑后端公共逻辑抽取+解耦 (#1884)
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 13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 19s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m27s
CI/CD Pipeline / Integration Tests (push) Successful in 3m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m45s
CI/CD Pipeline / Validate - Style (push) Successful in 4m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m58s
CI/CD Pipeline / Validate - Security (push) Successful in 8m1s
CI/CD Pipeline / Unit Tests (push) Successful in 9m0s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m36s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m31s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m48s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 51s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m4s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 53h34m26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 53h42m14s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 53h42m15s
CI/CD Pipeline / Deploy Production (push) Failing after 59h59m30s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 60h8m2s
CI/CD Pipeline / PR Build Web Image (push) Failing after 60h8m44s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 53h41m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 53h41m49s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 53h41m50s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 53h41m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 53h41m50s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 53h41m55s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 53h34m0s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 53h34m0s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 53h33m56s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 53h33m56s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 53h41m43s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 53h41m43s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 53h41m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 53h41m49s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 53h41m47s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 60h8m17s
CI/CD Pipeline / PR Build API Image (push) Failing after 60h8m18s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 60h7m36s
CI/CD Pipeline / CI Gate (push) Failing after 59h59m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 59h59m9s
CI/CD Pipeline / Canary Release to Production (push) Failing after 59h59m4s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 59h59m9s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 60h7m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 60h8m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 60h8m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 54h17m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 60h34m30s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 03:15:00 +08:00
xiaoxia 6ff8d48c15 refactor(web): 智能剪辑 & AI数字人 前端代码审查与重构 (#1883)
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 / Build Staging Web Image (push) Successful in 1m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m10s
CI/CD Pipeline / Validate - Style (push) Successful in 4m33s
CI/CD Pipeline / Integration Tests (push) Successful in 5m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Security (push) Successful in 10m36s
CI/CD Pipeline / Unit Tests (push) Successful in 10m43s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 60h28m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 60h39m0s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 60h35m47s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 60h38m38s
CI/CD Pipeline / PR Build API Image (push) Failing after 60h38m38s
CI/CD Pipeline / Build Production Web Image (push) Failing after 60h27m50s
CI/CD Pipeline / CI Gate (push) Failing after 60h27m50s
CI/CD Pipeline / Canary Release to Production (push) Failing after 60h27m46s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 60h35m46s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 60h35m46s
CI/CD Pipeline / PR Build Web Image (push) Failing after 60h38m38s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 60h38m40s
CI/CD Pipeline / Deploy Production (push) Failing after 60h27m46s
CI/CD Pipeline / Build Production API Image (push) Failing after 60h27m50s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 02:44:41 +08:00
LingYing Agent da1d55ef4e chore: 触发staging部署(批量变体P0修复)
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 / 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 24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 57s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m41s
CI/CD Pipeline / Validate - Style (push) Successful in 4m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m34s
CI/CD Pipeline / Validate - Security (push) Successful in 8m42s
CI/CD Pipeline / Unit Tests (push) Successful in 10m0s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 60h57m49s
CI/CD Pipeline / Build Production API Image (push) Failing after 60h57m54s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 61h7m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 61h7m56s
CI/CD Pipeline / Build Production Web Image (push) Failing after 60h57m28s
CI/CD Pipeline / CI Gate (push) Failing after 60h57m28s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 61h7m4s
CI/CD Pipeline / Canary Release to Production (push) Failing after 60h57m23s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 60h57m28s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 61h7m4s
CI/CD Pipeline / PR Build Web Image (push) Failing after 61h7m30s
CI/CD Pipeline / PR Build API Image (push) Failing after 61h7m30s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 61h7m30s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 61h7m35s
2026-09-14 02:15:43 +08:00
xiaoxia 00f8c0b566 fix(web): 修复批量变体预览3个P0 bug (#1881)
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 5s
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m3s
CI/CD Pipeline / Validate - Style (push) Successful in 4m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m7s
CI/CD Pipeline / Validate - Security (push) Successful in 9m9s
CI/CD Pipeline / Unit Tests (push) Successful in 11m6s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 61h22m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 61h22m18s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 61h32m37s
CI/CD Pipeline / PR Build API Image (push) Failing after 61h33m0s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 61h32m11s
CI/CD Pipeline / CI Gate (push) Failing after 61h21m52s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 61h32m11s
CI/CD Pipeline / PR Build Web Image (push) Failing after 61h33m0s
CI/CD Pipeline / Canary Release to Production (push) Failing after 61h21m47s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 61h21m52s
CI/CD Pipeline / Build Production API Image (push) Failing after 61h21m52s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 61h32m59s
CI/CD Pipeline / Frontend Lint (push) Failing after 61h33m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 61h33m5s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 01:50:15 +08:00
xiaoxia 1f01d6df25 fix(worker): 修复批量变体3个P0 bug (#1882)
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 8s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Validate - Style (push) Successful in 4m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m37s
CI/CD Pipeline / Validate - Security (push) Successful in 10m15s
CI/CD Pipeline / Unit Tests (push) Successful in 11m28s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 61h50m19s
CI/CD Pipeline / PR Build Web Image (push) Failing after 61h53m42s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 61h53m17s
CI/CD Pipeline / PR Build API Image (push) Failing after 61h53m17s
CI/CD Pipeline / Build Production Web Image (push) Failing after 61h41m43s
CI/CD Pipeline / Build Production API Image (push) Failing after 61h41m43s
CI/CD Pipeline / Deploy Production (push) Failing after 61h41m37s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 61h41m42s
CI/CD Pipeline / Canary Release to Production (push) Failing after 61h41m36s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 61h49m53s
CI/CD Pipeline / Frontend Lint (push) Failing after 61h53m12s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 61h53m23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 61h49m53s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 01:29:57 +08:00
57 changed files with 2972 additions and 1884 deletions
+17 -5
View File
@@ -1022,12 +1022,22 @@ jobs:
BRANCH_TAG="${IMAGE_FULL}:${GITHUB_REF_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
# develop 分支额外推送 :dev tag(Watchtower 监听的滚动更新 tag)
if [ "${GITHUB_REF_NAME}" = "develop" ]; then
DEV_TAG="${IMAGE_FULL}:dev"
EXTRA_TAGS="$BRANCH_TAG $DEV_TAG"
PUSHED_TAGS_SUMMARY="${BRANCH_TAG} + ${DEV_TAG}"
else
EXTRA_TAGS="$BRANCH_TAG"
PUSHED_TAGS_SUMMARY="${BRANCH_TAG}"
fi
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "=== Docker build 尝试 $i/3 (${{ matrix.service_display }}) ==="
if EXTRA_TAGS="$BRANCH_TAG" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
if EXTRA_TAGS="$EXTRA_TAGS" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "✅ Docker build 成功"
break
fi
@@ -1040,7 +1050,7 @@ jobs:
fi
done
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${BRANCH_TAG})"
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${PUSHED_TAGS_SUMMARY})"
- name: Job duration summary
if: always()
@@ -1244,9 +1254,11 @@ jobs:
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
set -eux
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
# CI runner (act_runner) 部署在 116 staging 本机(116.62.226.203 公网 22 未开放),
# 默认走 127.0.0.1:22 本机 SSH,避免跨机网络依赖;可通过 secrets 覆盖。
staging_host="${STAGING_SSH_HOST:-127.0.0.1}"
staging_user="${STAGING_SSH_USER:-root}"
staging_port="${STAGING_SSH_PORT:-22222}"
staging_port="${STAGING_SSH_PORT:-22}"
echo "Host: $staging_host"
echo "Port: $staging_port"
@@ -1470,7 +1482,6 @@ jobs:
- validate-security
- validate-python
- unit-tests
- frontend-lint
- frontend-unit-test
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
strategy:
@@ -2124,3 +2135,4 @@ jobs:
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# CI retry trigger
+1
View File
@@ -0,0 +1 @@
retrigger3
+1
View File
@@ -263,3 +263,4 @@ pytest --cov=packages --cov-report=html
---
**License**: MIT
<!-- CI trigger: 1788229339 -->
@@ -766,7 +766,12 @@ def generate_cover(
storage_svc = get_shared_storage_service()
mk_client = get_mediakit_client()
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
# #1901 统一读 "title",兼容老数据 "title_config"
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
if not isinstance(_e2_title_cfg, dict) or not (_e2_title_cfg.get("text") or "").strip():
_alt = (plan.config or {}).get("title_config", {}) or {}
if isinstance(_alt, dict):
_e2_title_cfg = _alt
if not isinstance(_e2_title_cfg, dict):
_e2_title_cfg = {}
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
+23 -141
View File
@@ -58,41 +58,10 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
"""[已下沉] 路由层兼容别名 → app.services.generation_common.query_voice_durations。"""
from app.services.generation_common import query_voice_durations
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
#1855 P0修复:不再对 voice_ids 去重,保持与调用方传入顺序/长度一致,
允许同配音id多次出现时返回相同时长(支持"同配音N变体"的时长对齐)。
"""
# 先去重查询(IN 查询性能优化),但最终按原始 voice_ids 顺序返回
raw_ids = list(voice_ids or [])
if not raw_ids:
return []
# 去重且保序,用于 SQL IN 查询;空字符串/None 视为无效id → 0.0
unique_ids: list[str] = []
_seen: set[str] = set()
for v in raw_ids:
if v and v not in _seen:
_seen.add(v)
unique_ids.append(v)
if not unique_ids:
return [0.0 for _ in raw_ids]
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(unique_ids)).all()
dur_map: dict[str, float] = {}
for row in rows:
try:
dur_map[row[0]] = float(row[1] or 0.0)
except (TypeError, ValueError):
dur_map[row[0]] = 0.0
# 按原始 voice_ids 顺序返回,保持长度一致;空/None/未查到 → 0.0
return [dur_map.get(v, 0.0) if v else 0.0 for v in raw_ids]
except Exception:
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
return [0.0 for _ in raw_ids]
return query_voice_durations(db, voice_ids)
def _to_generation_task_response(task) -> GenerationTaskResponse:
@@ -198,61 +167,10 @@ def _writeback_edit_plan_config(
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。"""
from app.services.generation_common import writeback_edit_plan_config
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
# 标题变化,清除旧封面
if "cover" in merged:
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id,
old_title_text,
new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
return writeback_edit_plan_config(plan_id, task_id, title_config, db)
def _resolve_project_and_library(
@@ -503,25 +421,14 @@ def create_generation_task(
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
voice_durations = _query_voice_durations(db, variant_voices)
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源)
batch_source_plan_id = request.source_edit_plan_id
if not batch_source_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(公共函数)
from app.services.generation_common import resolve_latest_plan_by_template
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
batch_source_plan_id = _latest.id
except Exception:
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
batch_source_plan_id = (
request.source_edit_plan_id
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
or ""
)
if not batch_source_plan_id and not request.variant_plan_ids:
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
@@ -566,24 +473,10 @@ def create_generation_task(
) from clone_err
variant_plan_ids.append(_plan0.id)
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值
def _collect_segments(pid):
segs = {}
_sk, _pg = 0, 500
while True:
_b = _plan_svc._clip_repo.list_by_plan(pid, skip=_sk, limit=_pg)
if not _b:
break
for _c in _b:
if _c.asset_id and float(_c.duration or 0) > 0:
_st = float(_c.start_time or 0.0)
segs.setdefault(_c.asset_id, []).append((_st, _st + float(_c.duration)))
if len(_b) < _pg:
break
_sk += _pg
return segs
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数)
from app.services.generation_common import collect_plan_segments as _collect_segments
_batch_segments = _collect_segments(_plan0.id)
_batch_segments = _collect_segments(_plan0.id, _plan_svc._clip_repo)
# 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让)
for task_index in range(1, count):
@@ -631,7 +524,7 @@ def create_generation_task(
# #1855 P0:把新变体的clips区间追加到batch_segments,供下一变体避让
try:
_new_segs = _collect_segments(variant.id)
_new_segs = _collect_segments(variant.id, _plan_svc._clip_repo)
for _aid, _ivs in _new_segs.items():
_batch_segments.setdefault(_aid, []).extend(_ivs)
except Exception:
@@ -659,24 +552,13 @@ def create_generation_task(
)
_single_vd: list[float] = _query_voice_durations(db, _voices)
_single_dur = _single_vd[0] if _single_vd else 0.0
_single_plan = request.source_edit_plan_id
if not _single_plan and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
from app.services.generation_common import resolve_latest_plan_by_template
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
_single_plan = _latest.id
except Exception:
logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True)
_single_plan = (
request.source_edit_plan_id
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
or ""
)
if _single_dur > 0 and _single_plan:
from app.services.edit_plan_service import EditPlanService
@@ -90,25 +90,12 @@ def create_variant_plans(
except VariantVoiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新(公共函数)
from app.services.generation_common import resolve_latest_plan_by_template
source_plan_id = request.source_edit_plan_id.strip()
if not source_plan_id and request.template_id.strip():
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id.strip(),
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
source_plan_id = _latest.id
except Exception:
logger.exception("[variant-plans] 源 plan 解析失败")
source_plan_id = resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) or ""
if not source_plan_id:
raise HTTPException(
+20 -6
View File
@@ -287,10 +287,15 @@ def retry_voice_clone(
return _to_response(profile)
_ALLOWED_PREVIEW_EMOTIONS = {"", "natural", "excited", "calm", "friendly"}
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
def get_voice_clone_preview(
clone_id: str,
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"),
emotion: str = Query("", description="情绪:natural/excited/calm/friendly,空字符串为默认自然"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
@@ -298,11 +303,17 @@ def get_voice_clone_preview(
"""获取克隆音色试听音频(实时 TTS 合成)。
- 克隆音色必须处于 ready 状态
- 使用默认试听文本时,结果缓存 7 天
- 可传入自定义 text 参数试听不同文本
- 使用默认试听文本时,结果缓存 7 天(仅默认 text+speed=1.0+emotion=空 组合缓存)
- 可传入自定义 text/speed/emotion 试听不同效果
"""
import time
if emotion not in _ALLOWED_PREVIEW_EMOTIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的 emotion 值: {emotion},可选: natural/excited/calm/friendly 或留空",
)
use_case = GetVoiceCloneUseCase(repository)
try:
profile = use_case.execute(clone_id, authenticated_user.user.id)
@@ -315,8 +326,8 @@ def get_voice_clone_preview(
detail=f"Voice clone is not ready (current status: {profile.status})",
)
# 有自定义文本时不缓存
use_cache = not text.strip()
# 仅默认试听文本 + 默认 speed + 默认 emotion 时使用缓存
use_cache = (not text.strip()) and abs(speed - 1.0) < 1e-6 and (not emotion)
if use_cache and clone_id in _clone_preview_cache:
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
@@ -337,12 +348,15 @@ def get_voice_clone_preview(
text=preview_text,
voice_id=profile.voice_id,
format="mp3",
speed=1.0,
speed=speed,
emotion=emotion,
)
except CosyVoiceError as e:
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 缓存(仅默认试听文本)
# 缓存(仅默认参数组合)
if use_cache:
_clone_preview_cache[clone_id] = (
result.audio_url,
+4 -19
View File
@@ -1003,25 +1003,10 @@ class EditPlanService:
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
plan_ids.append(plan0.id)
# #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值
def _collect_plan_segments(pid: str) -> dict[str, list[tuple[float, float]]]:
"""分页读取 plan 所有 clips,构建 {asset_id: [(start, end), ...]} 区间表。"""
segs: dict[str, list[tuple[float, float]]] = {}
_sk2, _pg2 = 0, 500
while True:
_b2 = self._clip_repo.list_by_plan(pid, skip=_sk2, limit=_pg2)
if not _b2:
break
for _c in _b2:
if _c.asset_id and float(_c.duration or 0) > 0:
_st = float(_c.start_time or 0.0)
segs.setdefault(_c.asset_id, []).append((_st, _st + float(_c.duration)))
if len(_b2) < _pg2:
break
_sk2 += _pg2
return segs
# #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值(公共函数)
from app.services.generation_common import collect_plan_segments as _collect_plan_segments
batch_segments_acc: dict[str, list[tuple[float, float]]] = _collect_plan_segments(plan0.id)
batch_segments_acc: dict[str, list[tuple[float, float]]] = _collect_plan_segments(plan0.id, self._clip_repo)
# 变体 1..N-1:独立选片(传入累积的 batch_segments 做区间避让)
for i in range(1, count):
@@ -1061,7 +1046,7 @@ class EditPlanService:
# #1855 P0:把当前新变体的 clips 区间追加到 batch_segments,供下一变体避让
try:
_new_segs = _collect_plan_segments(variant.id)
_new_segs = _collect_plan_segments(variant.id, self._clip_repo)
for _aid, _ivs in _new_segs.items():
batch_segments_acc.setdefault(_aid, []).extend(_ivs)
except Exception:
+189
View File
@@ -0,0 +1,189 @@
"""智能剪辑公共服务辅助函数(从 route 层下沉)。
集中管理:
- query_voice_durations:批量查询配音素材时长
- writeback_edit_plan_config:任务入队后回写 EditPlan.config
- collect_plan_segments:分页读取 plan clips 构建素材区间表(变体避让用)
- resolve_latest_plan_by_template:按 template_id + user_id 查最新 EditPlan
设计原则:
- 无副作用的纯查询 / 幂等写回;失败一律不阻断主流程(记日志 + 返回安全默认值)
- 不依赖 FastAPI / HTTPException,便于 service 层和 worker 复用
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
#1855 P0修复:不再对 voice_ids 去重,保持与调用方传入顺序/长度一致,
允许同配音id多次出现时返回相同时长(支持"同配音N变体"的时长对齐)。
"""
raw_ids = list(voice_ids or [])
if not raw_ids:
return []
unique_ids: list[str] = []
_seen: set[str] = set()
for v in raw_ids:
if v and v not in _seen:
_seen.add(v)
unique_ids.append(v)
if not unique_ids:
return [0.0 for _ in raw_ids]
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(unique_ids)).all()
dur_map: dict[str, float] = {}
for row in rows:
try:
dur_map[row[0]] = float(row[1] or 0.0)
except (TypeError, ValueError):
dur_map[row[0]] = 0.0
return [dur_map.get(v, 0.0) if v else 0.0 for v in raw_ids]
except Exception:
logger.warning("[generation_common] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
return [0.0 for _ in raw_ids]
def writeback_edit_plan_config(
plan_id: str,
task_id: str,
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[generation_common] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
if title_config:
# #1901 统一字段名为 "title"(worker sync_configs_to_plan 写的是 "title")
# 先读取新旧两个 key,判断标题文字是否变化
old_title_cfg = merged.get("title", {}) or {}
if not isinstance(old_title_cfg, dict) or not (old_title_cfg.get("text") or "").strip():
old_title_cfg = merged.get("title_config", {}) or {}
old_title_text = (old_title_cfg.get("text") or "").strip() if isinstance(old_title_cfg, dict) else ""
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
if "cover" in merged:
del merged["cover"]
logger.info(
"[generation_common] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id,
old_title_text,
new_title_text,
)
# 字段名归一化(font_size→size, font_preset→font, font_color→color),与 worker sync_configs_to_plan 保持一致
normalized = dict(title_config)
if "font_size" in normalized and "size" not in normalized:
normalized["size"] = normalized["font_size"]
if "font_preset" in normalized and "font" not in normalized:
normalized["font"] = normalized["font_preset"]
if "font_color" in normalized and "color" not in normalized:
normalized["color"] = normalized["font_color"]
merged["title"] = normalized
# 清掉旧 key,避免双字段并存
merged.pop("title_config", None)
plan_model.config = merged
db.commit()
logger.info(
"[generation_common] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[generation_common] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def collect_plan_segments(
plan_id: str,
clip_repo: Any,
*,
page_size: int = 500,
) -> dict[str, list[tuple[float, float]]]:
"""分页读取 plan 所有 clips,构建 {asset_id: [(start, end), ...]} 素材区间表。
用于 #1855 P0 批次内素材区间避让(变体间素材片段重叠控制)。
"""
segs: dict[str, list[tuple[float, float]]] = {}
sk, pg = 0, page_size
while True:
batch = clip_repo.list_by_plan(plan_id, skip=sk, limit=pg)
if not batch:
break
for c in batch:
if c.asset_id and float(c.duration or 0) > 0:
st = float(c.start_time or 0.0)
segs.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
if len(batch) < pg:
break
sk += pg
return segs
def resolve_latest_plan_by_template(
db: Session,
*,
template_id: str,
user_id: str,
) -> Optional[str]:
"""按 template_id + user_id 查找最新的 EditPlan.id(模板兜底用)。找不到返回 None。"""
if not (template_id or "").strip():
return None
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == template_id.strip(),
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
return latest.id if latest else None
except Exception:
logger.warning(
"[generation_common] 按template查找最新plan失败: template=%s user=%s",
template_id,
user_id,
exc_info=True,
)
return None
@@ -26,6 +26,10 @@ export interface BatchVariantPlansRequest {
count: number
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
source_edit_plan_id?: string
/** 统一配音 ID(共用配音模式);独立配音模式不传,改传 voice_library_ids */
voice_library_id?: string
/** 独立配音 ID 列表(长度=count,按变体序号一一对应);共用配音模式不传 */
voice_library_ids?: string[]
}
/** 单个变体的计划片段 */
@@ -36,6 +40,8 @@ export interface VariantPlan {
plan_id: string
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
clips: EditPlanClip[]
/** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */
voice_duration?: number
}
/** 批量变体计划响应 */
+2
View File
@@ -16,6 +16,7 @@ export interface TTSSynthesizeRequest {
voice_id?: string
output_name?: string
language?: string
emotion?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
@@ -103,6 +104,7 @@ export interface TTSPreviewRequest {
voice_id: string
speed?: number
pitch?: number
language?: string
emotion?: string // 情绪参数:natural/excited/calm/friendly
}
+3
View File
@@ -96,9 +96,12 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
export const getVoiceClonePreview = async (
cloneId: string,
text?: string,
options?: { speed?: number; emotion?: string },
): Promise<VoiceClonePreviewResponse> => {
const searchParams = new URLSearchParams()
if (text) searchParams.set("text", text)
if (options?.speed !== undefined) searchParams.set("speed", String(options.speed))
if (options?.emotion) searchParams.set("emotion", options.emotion)
const qs = searchParams.toString()
const response = await apiClient.get<VoiceClonePreviewResponse>(
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
-9
View File
@@ -25,15 +25,6 @@ body {
background-color: var(--bg-secondary);
}
/* ── 自定义字体 ── */
@font-face {
font-family: "华康俪金黑";
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* 滚动条 - V21 样式 */
::-webkit-scrollbar {
width: 8px;
@@ -227,11 +227,6 @@ const AiAvatarPage: React.FC = () => {
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
console.log("[对口型] 开始生成:", {
videoId: video.id,
mode: isPreSynth ? "pre-synth" : "tts-direct",
textLen: state.scriptText.length,
})
const asset = await getAssetById(video.id)
const videoUrl = asset?.file_url
if (!videoUrl) {
@@ -262,9 +257,7 @@ const AiAvatarPage: React.FC = () => {
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)
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
@@ -274,11 +267,6 @@ const AiAvatarPage: React.FC = () => {
try {
const updated = await getLipsyncJob(job.id)
state.setLipsyncJob(updated)
console.log("[对口型] 轮询状态:", {
id: updated.id,
status: updated.status,
error: updated.error_message,
})
if (updated.status === "completed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
setLipsyncStatus("completed")
@@ -139,13 +139,6 @@ export function PanelVoiceSelector({
}
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({
@@ -154,10 +147,6 @@ export function PanelVoiceSelector({
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("合成试听失败:未返回音频")
@@ -22,8 +22,8 @@ export interface ScriptSentence {
endTime: number
}
/** 句子分隔符:中英文句号/问号/感叹号/分号/逗号/换行(覆盖中文短视频常用断句) */
const SENTENCE_SPLIT_RE = /[。!?!??!;;,,\n\r]+/
/** 句子分隔符:中英文句号/问号/感叹号/分号/换行(按句断,不在逗号处切;保持与后端一致) */
const SENTENCE_SPLIT_RE = /[。!?!??!;;\n\r]+/
/**
* 分句并计算每句的起止时间。
@@ -5,6 +5,7 @@
import React from "react"
import type { ClipData, ClipType } from "../types"
import type { TitleConfig } from "@/api/template-editor"
import { getFontFamily } from "@/pages/generate/constants"
interface SubtitleSettings {
enabled: boolean
@@ -80,7 +81,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
className="ep-preview-title"
style={{
fontSize: `${Math.min(titleConfig.font_size, 20)}px`,
fontFamily: titleConfig.font_preset,
fontFamily: getFontFamily(titleConfig.font_preset),
fontWeight: "bold",
fontStyle: "normal",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
@@ -106,7 +107,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
className="ep-preview-subtitle"
style={{
fontSize: `${Math.min(subtitleSettings.size, 14)}px`,
fontFamily: subtitleSettings.font,
fontFamily: getFontFamily(subtitleSettings.font),
top:
subtitleSettings.position === "top"
? "8px"
@@ -9,15 +9,8 @@ export const POSITION_OPTIONS = [
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
// FONT_OPTIONS 统一从 generate/constants 导入,避免多处维护遗漏
export { FONT_OPTIONS } from "@/pages/generate/constants"
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
@@ -7,15 +7,8 @@ export const POSITION_OPTIONS = [
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
// FONT_OPTIONS 统一从 generate/constants 导入
export { FONT_OPTIONS } from "@/pages/generate/constants"
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
@@ -231,9 +231,13 @@ const GeneratePage: React.FC = () => {
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
// P0 fix:批量变体计划请求需携带配音参数,避免后端按"无配音"选片导致 clips 时长与配音错位
const batchVoiceLibraryId =
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
const {
clipsByVariant: variantClips,
planIdsByVariant: variantPlanIds,
voiceDurationsByVariant: variantVoiceDurations,
loading: variantClipsLoading,
error: variantClipsError,
retry: retryVariantClips,
@@ -243,6 +247,9 @@ const GeneratePage: React.FC = () => {
templateId: selectedTemplate || "",
assetIds: previewAssetIds,
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
voiceLibraryId: batchVoiceLibraryId,
voiceLibraryIds: voiceLibraryIds || [],
voiceModePerVideo,
})
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
@@ -476,6 +483,7 @@ const GeneratePage: React.FC = () => {
titles={previewTitles}
titleSettings={titleSettings}
voiceAudioUrls={variantVoiceAudioUrls}
voiceDurations={variantVoiceDurations}
variantClips={variantClips}
clipsLoading={variantClipsLoading}
clipsError={variantClipsError}
@@ -31,6 +31,12 @@ interface CanvasPreviewGridProps {
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
*/
voiceAudioUrls?: (string | null)[]
/**
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined,
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
* 长度=count,undefined 项表示该变体未提供后端时长。
*/
voiceDurations?: (number | undefined)[]
/**
* 各变体的后端真实片段(#1744/#1750):长度=count。
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
@@ -56,6 +62,7 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
titles,
titleSettings,
voiceAudioUrls,
voiceDurations,
variantClips,
clipsLoading = false,
clipsError = false,
@@ -120,6 +127,7 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
serverClips={variantClips[i]}
variantTitle={titles[i] || ""}
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
voiceDurationHint={voiceDurations?.[i]}
activePlayToken={activePlayToken}
onPlayTokenChange={setActivePlayToken}
compact
@@ -1,25 +1,23 @@
/**
* 前端预览播放器 — Canvas + WebCodecs 方案
* 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI)
*
* 架构:
* - 浏览器支持 WebCodecs → Canvas 渲染(帧级精确控制 + 标题合成)
* - 浏览器不支持 → fallback 到多 video 元素方案
* - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度),
* 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。
* UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。
* - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除),
* 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。
*
* 对外 API 不变:assets, template, videoRatio, ready, voiceAudioUrl
* 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。
*/
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
SoundOutlined,
LoadingOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
import { usePreviewAudio } from "../hooks/usePreviewAudio"
import { PreviewControls } from "./PreviewControls"
import { getFontFamily } from "../constants"
interface FrontendPreviewPlayerProps {
assets: AssetItem[]
@@ -58,12 +56,11 @@ interface FrontendPreviewPlayerProps {
activePlayToken?: number | null
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
onPlayTokenChange?: (token: number | null) => void
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, "0")}`
/**
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
* 未提供则在 audio loadedmetadata 后自测兜底。
*/
voiceDurationHint?: number
}
/**
@@ -116,6 +113,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
ready,
serverClips,
voiceAudioUrl,
voiceDurationHint,
titleSettings,
onTitlePositionChange,
playToken,
@@ -124,26 +122,11 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
activePlayToken = null,
onPlayTokenChange,
}) => {
// #1754:测量配音时长,计算缩放因子
const [voiceDuration, setVoiceDuration] = useState(0)
useEffect(() => {
if (!voiceAudioUrl) {
setVoiceDuration(0)
return
}
const audio = new Audio()
audio.preload = "metadata"
const onLoaded = () => {
if (audio.duration && isFinite(audio.duration)) {
setVoiceDuration(audio.duration)
}
}
audio.addEventListener("loadedmetadata", onLoaded)
audio.src = voiceAudioUrl
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
}
}, [voiceAudioUrl])
// #1754→P0:配音时长作为音画时长锚点。
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
)
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
const rawClipsDuration = useMemo(() => {
@@ -189,9 +172,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
? (titleSettings.posY / playRes.height) * 100
: null
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
// ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)──
const draggingTitleRef = useRef(false)
const titleDragRef = useRef<HTMLDivElement>(null)
const playerContainerRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return
@@ -207,7 +191,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (!draggingTitleRef.current || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
if (titleDragRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
@@ -222,7 +205,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
// 拖拽结束时才调用 onTitlePositionChange 保存最终位置
if (onTitlePositionChange && playerContainerRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
@@ -243,7 +225,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
[onTitlePositionChange, playRes.width, playRes.height],
)
const playerContainerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(0)
useEffect(() => {
const el = playerContainerRef.current
@@ -268,234 +249,157 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
// 描边/阴影也要按缩放比例放大
const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1
const titleStrokeWidth = Math.max(1, 2 * titleScale)
const titleShadowBlur = 4 * titleScale
const titleShadowOffset = 2 * titleScale
// 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
const useWebCodecs = false
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
const canvasRef = useRef<HTMLCanvasElement>(null)
// ── Canvas 播放器(WebCodecs 路径) ──
const canvasTitle = titleSettings
? {
text: effectiveTitle || "标题预览",
fontSize: titleSettings.size,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
position: titleSettings.position || "top",
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
}
: undefined
const canvasSegments = useMemo(
() =>
segments.map((s) => ({
assetId: s.assetId,
videoUrl: s.videoUrl,
startTime: s.startTime,
endTime: s.endTime,
})),
[segments],
)
// WebCodecs 解码失败后强制走 video fallback
const [forceVideoFallback, setForceVideoFallback] = useState(false)
const handleCanvasError = useCallback((err: Error) => {
console.error("[FrontendPreviewPlayer] Canvas decode Error, switching to video fallback:", err)
setForceVideoFallback(true)
}, [])
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
canvasRef,
useWebCodecs && !forceVideoFallback ? canvasSegments : [],
useWebCodecs && !forceVideoFallback ? canvasTitle : undefined,
handleCanvasError,
useWebCodecs && !forceVideoFallback,
)
// WebCodecs 报告解码失败时自动切换到 video fallback
useEffect(() => {
if (canvasState.hasDecodeError && !forceVideoFallback) {
console.warn("[FrontendPreviewPlayer] hasDecodeError detected, forcing video fallback")
setForceVideoFallback(true)
}
}, [canvasState.hasDecodeError, forceVideoFallback])
// ── Video 播放器(fallback 路径) ──
// ── Video 播放器(默认路径,浏览器原生硬件解码) ──
const {
isPlaying: videoIsPlaying,
currentTime: videoCurrentTime,
totalDuration: videoTotalDuration,
currentSegmentIndex: videoCurrentSegIdx,
canPlay: videoCanPlay,
togglePlayPause: videoTogglePlayPause,
seekTo: videoSeekTo,
pause: videoPause,
isPlaying,
currentTime,
totalDuration,
currentSegmentIndex,
canPlay,
togglePlayPause,
seekTo,
pause,
videoRefs,
} = useSegmentScheduler(segments)
// 选择哪条路径的状态(WebCodecs 解码失败时强制走 video fallback)
const effectiveUseWebCodecs = useWebCodecs && !forceVideoFallback
const isPlaying = effectiveUseWebCodecs ? canvasState.isPlaying : videoIsPlaying
const currentTime = effectiveUseWebCodecs ? canvasState.currentTime : videoCurrentTime
const totalDuration = effectiveUseWebCodecs ? canvasState.duration : videoTotalDuration
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
// P0 fix:以配音时长为音画同步锚点。
// 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。
const effectiveTotalDuration =
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
const [muted, setMuted] = useState(false)
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
const hasVoice = !!voiceAudioUrl
useEffect(() => {
if (!voiceAudioUrl) {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
audioRef.current = null
}
return
}
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.preload = "auto"
}
if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
audioRef.current.muted = muted
}, [voiceAudioUrl, muted])
// 音频 ended:兜底触发暂停与释放播放权
const handleAudioEnded = useCallback(() => {
if (!isPlaying) return
pause()
if (playToken != null) onPlayTokenChange?.(null)
}, [isPlaying, pause, playToken, onPlayTokenChange])
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying && !prevIsPlayingRef.current) {
audio.currentTime = currentTime
audio.play().catch(() => {})
} else if (!isPlaying && prevIsPlayingRef.current) {
audio.pause()
}
prevIsPlayingRef.current = isPlaying
}, [isPlaying, currentTime])
const {
seekTo: audioSeekTo,
ensurePlayingAt: audioEnsurePlayingAt,
pause: audioPause,
} = usePreviewAudio({
voiceAudioUrl,
voiceDurationHint,
muted,
isPlaying,
currentTime,
onVoiceDurationChange: setVoiceDuration,
onEnded: handleAudioEnded,
})
// 片段切换时同步音频(仅 fallback 路径需要)
const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx
// 片段切换时同步音频时间(video fallback)
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src || !isPlaying) return
audio.currentTime = currentTime
if (!isPlaying) return
audioSeekTo(currentTime)
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segmentSyncKey, isPlaying])
}, [currentSegmentIndex, isPlaying])
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
// 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
const tailStartRef = useRef<number>(0)
const tailBaseRef = useRef<number>(0)
const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause })
tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }
useEffect(() => {
const needTail =
!!voiceAudioUrl &&
voiceDuration > 0 &&
!isPlaying &&
typeof currentTime === "number" &&
currentTime >= totalDuration - 0.1 &&
currentTime < voiceDuration - 0.1
if (needTail && tailCurrentTime === null) {
tailBaseRef.current = currentTime
tailStartRef.current = performance.now()
setTailCurrentTime(currentTime)
tailAudioRef.current.ensurePlayingAt(currentTime)
return
}
if (!needTail && tailCurrentTime !== null) {
setTailCurrentTime(null)
}
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime])
useEffect(() => {
if (tailCurrentTime === null) return
let raf = 0
const tick = () => {
const elapsed = (performance.now() - tailStartRef.current) / 1000
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
setTailCurrentTime(t)
tailAudioRef.current.ensurePlayingAt(t)
if (t >= (voiceDuration || 0) - 0.05) {
tailAudioRef.current.pause()
if (playToken != null) onPlayTokenChange?.(null)
setTailCurrentTime(null)
return
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
const handleSeekTo = useCallback(
(time: number) => {
if (effectiveUseWebCodecs) {
canvasControls.seek(time)
} else {
videoSeekTo(time)
}
const audio = audioRef.current
if (audio && audio.src) {
audio.currentTime = time
}
setTailCurrentTime(null)
seekTo(time)
audioSeekTo(time)
},
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
[seekTo, audioSeekTo],
)
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ──
useEffect(() => {
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) canvasControls.pause()
} else if (isPlaying) {
videoPause()
if (isPlaying) {
pause()
}
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
// isPlaying 不放依赖:只在 token 变化时执行一次暂停
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePlayToken, playToken, effectiveUseWebCodecs])
}, [activePlayToken, playToken])
const handleTogglePlay = useCallback(() => {
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) {
canvasControls.pause()
onPlayTokenChange?.(null)
} else {
if (playToken != null) onPlayTokenChange?.(playToken)
canvasControls.play()
}
} else {
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
videoTogglePlayPause()
}
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
togglePlayPause()
}, [togglePlayPause, isPlaying, playToken, onPlayTokenChange])
// P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结)
useEffect(() => {
if (!isPlaying) return
if (!voiceAudioUrl || voiceDuration <= 0) return
if (displayCurrentTime < voiceDuration - 0.08) return
pause()
audioPause()
if (playToken != null) onPlayTokenChange?.(null)
}, [
effectiveUseWebCodecs,
canvasState.isPlaying,
canvasControls,
videoTogglePlayPause,
isPlaying,
displayCurrentTime,
voiceAudioUrl,
voiceDuration,
pause,
audioPause,
playToken,
onPlayTokenChange,
])
// ── 进度条拖拽 ──
const [isDragging, setIsDragging] = useState(false)
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
handleSeekTo(ratio * totalDuration)
},
[totalDuration, handleSeekTo],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
handleProgressClick(e)
},
[handleProgressClick],
)
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
handleSeekTo(ratio * totalDuration)
}
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, totalDuration, handleSeekTo])
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ──
const canvasContainerRef = useRef<HTMLDivElement>(null)
// ── 未就绪 ──
if (!ready || !assets.length) {
return (
@@ -529,7 +433,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── 无播放片段 ──
if (!canPlay) {
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
return (
<div
style={{
@@ -549,48 +452,15 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
padding: 24,
}}
>
{isBuffering ? (
<>
<LoadingOutlined style={{ fontSize: 40, color: "#fff", marginBottom: 12 }} spin />
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14, margin: 0 }}>加载中...</p>
</>
) : showDecodeError ? (
<>
<PlayCircleOutlined style={{ fontSize: 40, color: "#ef4444", marginBottom: 12 }} />
<p
style={{
color: "rgba(255,255,255,0.9)",
fontSize: 14,
margin: "0 0 4px",
fontWeight: 500,
}}
>
视频解码失败
</p>
<p
style={{
color: "rgba(255,255,255,0.5)",
fontSize: 12,
margin: 0,
textAlign: "center",
}}
>
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
</p>
</>
) : (
<>
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
暂无可播放素材
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
请先在左侧选择素材
</p>
</>
)}
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
暂无可播放素材
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
请先在左侧选择素材
</p>
</div>
)
}
@@ -612,53 +482,30 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
: "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
}}
>
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
{effectiveUseWebCodecs && (
<div
ref={canvasContainerRef}
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
{segments.map((seg, i) => (
<video
key={seg.assetId}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
zIndex: 1,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === currentSegmentIndex ? 1 : 0,
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
}}
>
<canvas
ref={canvasRef}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</div>
)}
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
{!effectiveUseWebCodecs &&
segments.map((seg, i) => (
<video
key={seg.assetId}
muted={hasVoice || muted}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === videoCurrentSegIdx ? 1 : 0,
pointerEvents: i === videoCurrentSegIdx ? "auto" : "none",
}}
playsInline
/>
))}
muted={hasVoice || muted}
playsInline
/>
))}
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{titleSettings?.title && (
@@ -710,7 +557,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<span
style={{
fontSize: `${titleFontSizePx}px`,
fontFamily: titleSettings.font || "思源黑体",
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
color: titleSettings.color || "#ffffff",
fontWeight: titleSettings.bold ? 700 : 400,
fontStyle: titleSettings.italic ? "italic" : "normal",
@@ -735,200 +582,19 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
</div>
)}
{/* 中央播放按钮 */}
{!isPlaying && (
<button
onClick={handleTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
{segments.length > 0 && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={() => setMuted((m) => !m)}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${videoCurrentSegIdx + 1} / ${segments.length}`}
</div>
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={handleTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatTime(currentTime)} / {formatTime(totalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</div>
<PreviewControls
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
muted={muted}
onToggleMute={() => setMuted((m) => !m)}
hasSegments={segments.length > 0}
segmentIndex={currentSegmentIndex}
segmentCount={segments.length}
currentTime={displayCurrentTime}
totalDuration={effectiveTotalDuration}
onSeek={handleSeekTo}
compact={compact}
/>
</div>
)
}
@@ -0,0 +1,195 @@
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import { PreviewProgressBar } from "./PreviewProgressBar"
interface PreviewControlsProps {
isPlaying: boolean
onTogglePlay: () => void
muted: boolean
onToggleMute: () => void
hasSegments: boolean
segmentIndex: number
segmentCount: number
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条)
*/
export const PreviewControls: React.FC<PreviewControlsProps> = ({
isPlaying,
onTogglePlay,
muted,
onToggleMute,
hasSegments,
segmentIndex,
segmentCount,
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
return (
<>
{/* 静音/有声切换(#1741):左上角 */}
{hasSegments && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={onToggleMute}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${segmentIndex + 1} / ${segmentCount}`}
</div>
{/* 中央播放按钮 */}
{!isPlaying && (
<button
onClick={onTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={onTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<PreviewProgressBar
currentTime={currentTime}
totalDuration={totalDuration}
onSeek={onSeek}
compact={compact}
/>
</div>
</>
)
}
@@ -0,0 +1,108 @@
import React, { useCallback, useEffect, useRef, useState } from "react"
import { formatDuration } from "../utils/formatDuration"
interface PreviewProgressBarProps {
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 进度条组件:点击/拖拽 seek
*/
export const PreviewProgressBar: React.FC<PreviewProgressBarProps> = ({
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)
const seekByClientX = useCallback(
(clientX: number) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
onSeek(ratio * totalDuration)
},
[totalDuration, onSeek],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
seekByClientX(e.clientX)
},
[seekByClientX],
)
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX)
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, seekByClientX])
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
return (
<>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</>
)
}
+1 -10
View File
@@ -50,15 +50,7 @@ export const POSITION_OPTIONS = [
]
/* ── 标题字体选项 ── */
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"]
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
export const FONT_FAMILY_MAP: Record<string, string> = {
@@ -68,7 +60,6 @@ export const FONT_FAMILY_MAP: Record<string, string> = {
PingFang: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
微软雅黑: '"Microsoft YaHei", "PingFang SC", sans-serif',
楷体: '"KaiTi", "STKaiti", "DFKai-SB", serif',
华康俪金黑: '"华康俪金黑", "DFLiJinHei-W8", "Source Han Sans SC", "Microsoft YaHei", sans-serif',
}
export function getFontFamily(font: string): string {
@@ -171,7 +171,6 @@ export function useBatchCovers({
let okCount = 0
let failCount = 0
for (const i of pending) {
// eslint-disable-next-line no-await-in-loop
const ok = await generateOne(i)
if (ok) okCount += 1
else failCount += 1
@@ -26,6 +26,8 @@ export interface BatchVariantClipsState {
clipsByVariant: EditPlanClip[][]
/** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */
planIdsByVariant: string[]
/** 各变体的后端返回配音时长(秒);未就绪/未返回为 undefined */
voiceDurationsByVariant: (number | undefined)[]
/** 是否正在向后端申请变体计划 */
loading: boolean
/** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */
@@ -44,6 +46,12 @@ interface UseBatchVariantPlansOptions {
assetIds: string[]
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
sourcePlanId?: string
/** 统一配音 ID(共用配音模式),参考 useGenerateVideo voiceLibraryId 计算 */
voiceLibraryId?: string
/** 独立配音 ID 列表(每变体一条),voiceModePerVideo=true 时使用 */
voiceLibraryIds?: string[]
/** 是否启用独立配音模式(每变体各自一条配音) */
voiceModePerVideo?: boolean
}
export function useBatchVariantPlans({
@@ -52,9 +60,13 @@ export function useBatchVariantPlans({
templateId,
assetIds,
sourcePlanId = "",
voiceLibraryId = "",
voiceLibraryIds = [],
voiceModePerVideo = false,
}: UseBatchVariantPlansOptions): BatchVariantClipsState {
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
const [voiceDurationsByVariant, setVoiceDurationsByVariant] = useState<(number | undefined)[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
@@ -70,17 +82,30 @@ export function useBatchVariantPlans({
setLoading(true)
setError(false)
try {
// 配音参数:与 useGenerateVideo 保持一致的传参逻辑
// - 独立配音模式 + voiceLibraryIds 非空:传 voice_library_ids
// - 统一配音:传 voice_library_id
// - 都没选:不传
const voiceParam: { voice_library_id?: string; voice_library_ids?: string[] } = {}
if (voiceModePerVideo && voiceLibraryIds.length > 0) {
voiceParam.voice_library_ids = voiceLibraryIds
} else if (voiceLibraryId) {
voiceParam.voice_library_id = voiceLibraryId
}
const resp = await createBatchVariantPlans({
template_id: templateId,
asset_ids: assetIds,
count,
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
...voiceParam,
})
if (seq !== requestSeqRef.current) return
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
const planIds: string[] = Array.from({ length: count }, () => "")
const voiceDurs: (number | undefined)[] = Array.from({ length: count }, () => undefined)
for (const item of items) {
const idx = item.variant_index
if (idx < 0 || idx >= count) continue
@@ -88,6 +113,9 @@ export function useBatchVariantPlans({
clips[idx] = (item.clips || [])
.filter((c) => c && c.asset_id && c.status === "ready")
.sort((a, b) => a.order - b.order)
if (typeof item.voice_duration === "number" && item.voice_duration > 0) {
voiceDurs[idx] = item.voice_duration
}
}
// 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充)
const incomplete = clips.some((list) => list.length === 0)
@@ -95,10 +123,12 @@ export function useBatchVariantPlans({
console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败")
setClipsByVariant([])
setPlanIdsByVariant([])
setVoiceDurationsByVariant([])
setError(true)
} else {
setClipsByVariant(clips)
setPlanIdsByVariant(planIds)
setVoiceDurationsByVariant(voiceDurs)
setError(false)
}
} catch (err) {
@@ -107,11 +137,20 @@ export function useBatchVariantPlans({
console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err)
setClipsByVariant([])
setPlanIdsByVariant([])
setVoiceDurationsByVariant([])
setError(true)
} finally {
if (seq === requestSeqRef.current) setLoading(false)
}
}, [templateId, count, sourcePlanId, assetIds])
}, [
templateId,
count,
sourcePlanId,
assetIds,
voiceLibraryId,
voiceLibraryIds,
voiceModePerVideo,
])
/** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */
const retry = useCallback(() => {
@@ -125,24 +164,40 @@ export function useBatchVariantPlans({
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
setVoiceDurationsByVariant((prev) => (prev.length === 0 ? prev : []))
setLoading((prev) => (prev === false ? prev : false))
setError((prev) => (prev === false ? prev : false))
lastKeyRef.current = ""
return
}
const voiceKey = voiceModePerVideo
? `per:${[...voiceLibraryIds].sort().join(",")}`
: `one:${voiceLibraryId}`
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
.sort()
.join(",")}`
.join(",")}|${voiceKey}`
if (key === lastKeyRef.current) return
lastKeyRef.current = key
load()
}, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce])
}, [
enabled,
templateId,
count,
sourcePlanId,
assetIds,
load,
retryNonce,
voiceLibraryId,
voiceLibraryIds,
voiceModePerVideo,
])
const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0)
return {
clipsByVariant,
planIdsByVariant,
voiceDurationsByVariant,
loading,
ready,
error,
File diff suppressed because it is too large Load Diff
@@ -51,7 +51,6 @@ export function useTitleCoverSync({
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTemplate, setTitleSettings, setCoverSettings])
// ↑ 移除 userTemplates,只在 selectedTemplate 真正变化时触发
}
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef } from "react"
interface UsePreviewAudioOptions {
voiceAudioUrl: string | undefined
voiceDurationHint: number | undefined
muted: boolean
isPlaying: boolean
currentTime: number
onVoiceDurationChange: (d: number) => void
onEnded: () => void
}
interface UsePreviewAudioReturn {
seekTo: (time: number) => void
ensurePlayingAt: (time: number) => void
pause: () => void
}
/**
* 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、
* ended 事件回调、seek 同步、末帧冻结期间续播。
*/
export function usePreviewAudio({
voiceAudioUrl,
voiceDurationHint,
muted,
isPlaying,
currentTime,
onVoiceDurationChange,
onEnded,
}: UsePreviewAudioOptions): UsePreviewAudioReturn {
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 外部 hint 初始化(自测值前的兜底)
useEffect(() => {
if (voiceDurationHint && voiceDurationHint > 0) {
onVoiceDurationChange(voiceDurationHint)
}
}, [voiceDurationHint, onVoiceDurationChange])
// 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended
useEffect(() => {
if (!voiceAudioUrl) {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
audioRef.current = null
}
return
}
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.preload = "auto"
}
if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
audioRef.current.muted = muted
const audio = audioRef.current
const onLoaded = () => {
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
onVoiceDurationChange(audio.duration)
}
}
const onEndedHandler = () => onEnded()
audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("ended", onEndedHandler)
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("ended", onEndedHandler)
}
}, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded])
// mute 变化即时同步
useEffect(() => {
if (audioRef.current) audioRef.current.muted = muted
}, [muted])
// 播放/暂停同步(跟随视频 isPlaying)
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying && !prevIsPlayingRef.current) {
if (Math.abs(audio.currentTime - currentTime) > 0.3) {
try {
audio.currentTime = currentTime
} catch {
/* ignore */
}
}
audio.play().catch(() => {})
} else if (!isPlaying && prevIsPlayingRef.current) {
audio.pause()
}
prevIsPlayingRef.current = isPlaying
}, [isPlaying, currentTime])
const seekTo = useCallback((time: number) => {
const audio = audioRef.current
if (audio && audio.src) {
try {
audio.currentTime = time
} catch {
/* ignore */
}
}
}, [])
const ensurePlayingAt = useCallback((time: number) => {
const audio = audioRef.current
if (!audio || !audio.src) return
try {
if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time
if (audio.paused) audio.play().catch(() => {})
} catch {
/* ignore */
}
}, [])
const pause = useCallback(() => {
try {
audioRef.current?.pause()
} catch {
/* ignore */
}
}, [])
return { seekTo, ensurePlayingAt, pause }
}
@@ -125,7 +125,6 @@ export function useVariantVoicePreview({
continue
}
try {
// eslint-disable-next-line no-await-in-loop
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
const audioUrl = res.audio_url || ""
@@ -11,6 +11,7 @@ import CloneModal from "@/components/voice/CloneModal"
import { VoiceCloneCard } from "./components/VoiceCloneCard"
import { VoiceCloneEmpty, VoiceCloneSkeleton, ToastContainer } from "./components/States"
import { EditNameDialog } from "./components/EditNameDialog"
import VoiceClonePreviewPanel from "./components/VoiceClonePreviewPanel"
import { useVoiceCloneList } from "./hooks/useVoiceCloneList"
import "./voice-clone.css"
@@ -47,6 +48,9 @@ const VoiceClone: React.FC = () => {
}
/>
{/* 音色试听面板(自定义文本 + 语速/情绪) */}
{!isLoading && voices.length > 0 && <VoiceClonePreviewPanel voices={voices} />}
{/* 加载状态 — 骨架屏 */}
{isLoading && <VoiceCloneSkeleton />}
@@ -0,0 +1,133 @@
/**
* 克隆音色试听面板
* - 选择就绪音色、输入试听文本、调节语速/情绪,点试听
* - 复用配音库 TTS 弹窗的 SpeedControl / EmotionControl 组件
*/
import React, { useState, useRef, useCallback } from "react"
import { message } from "antd"
import { SoundOutlined, LoadingOutlined } from "@ant-design/icons"
import { getVoiceClonePreview, type VoiceClone } from "@/api/voice-clone"
import SpeedControl from "@/pages/voices/components/tts-modal/SpeedControl"
import EmotionControl from "@/pages/voices/components/tts-modal/EmotionControl"
import { DEFAULT_TTS_EMOTION, type TtsEmotion } from "@/pages/voices/components/tts-modal/constants"
import { TTS_CONFIG } from "@/pages/voices/components/tts-modal/types"
interface VoiceClonePreviewPanelProps {
voices: VoiceClone[]
}
const DEFAULT_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的声音效果,希望你喜欢。"
const VoiceClonePreviewPanel: React.FC<VoiceClonePreviewPanelProps> = ({ voices }) => {
const readyVoices = voices.filter((v) => v.status === "ready")
const [selectedId, setSelectedId] = useState<string>(readyVoices[0]?.id ?? "")
const [previewText, setPreviewText] = useState(DEFAULT_PREVIEW_TEXT)
const [speed, setSpeed] = useState<number>(TTS_CONFIG.DEFAULT_SPEED)
const [emotion, setEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
const [previewing, setPreviewing] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const handlePreview = useCallback(async () => {
if (!selectedId) {
message.warning("请先选择要试听的音色")
return
}
const text = previewText.trim()
if (!text) {
message.warning("请输入试听文本")
return
}
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setPreviewing(true)
setAudioUrl(null)
try {
const res = await getVoiceClonePreview(selectedId, text, { speed, emotion })
setAudioUrl(res.audio_url)
const audio = new Audio(res.audio_url)
audioRef.current = audio
audio.play().catch(() => {
message.error("播放失败,请重试")
})
} catch (err) {
const msg = err instanceof Error ? err.message : "试听失败"
message.error(msg)
} finally {
setPreviewing(false)
}
}, [selectedId, previewText, speed, emotion])
if (readyVoices.length === 0) return null
return (
<div className="vc-preview-panel">
<div className="vc-preview-header">
<SoundOutlined style={{ color: "var(--primary-color)" }} />
<span className="vc-preview-title">音色试听</span>
</div>
<div className="vc-preview-body">
<div className="vc-preview-field">
<label className="vc-preview-label">选择音色</label>
<select
className="vc-preview-select"
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
>
{readyVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<div className="vc-preview-field">
<label className="vc-preview-label">试听文本</label>
<textarea
className="vc-preview-textarea"
value={previewText}
onChange={(e) => setPreviewText(e.target.value)}
rows={2}
maxLength={200}
placeholder="输入试听文本(最多200字)"
/>
</div>
<div className="vc-preview-grid">
<div className="vc-preview-field">
<SpeedControl speed={speed} onChange={setSpeed} />
</div>
<div className="vc-preview-field">
<EmotionControl emotion={emotion} onChange={setEmotion} />
</div>
</div>
<button
type="button"
className="vc-preview-btn"
onClick={handlePreview}
disabled={previewing || !selectedId || !previewText.trim()}
>
{previewing ? (
<>
<LoadingOutlined /> 合成中...
</>
) : (
<>▶ 开始试听</>
)}
</button>
{audioUrl && !previewing && (
<audio controls src={audioUrl} style={{ width: "100%", marginTop: 4 }} />
)}
</div>
</div>
)
}
export default VoiceClonePreviewPanel
@@ -464,3 +464,93 @@
padding: var(--space-sm);
}
}
/* ── 试听面板 ─────────────────────────────────────────── */
.vc-preview-panel {
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.vc-preview-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.vc-preview-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.vc-preview-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.vc-preview-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.vc-preview-label {
font-size: 13px;
color: var(--text-secondary);
}
.vc-preview-select,
.vc-preview-textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.vc-preview-select:focus,
.vc-preview-textarea:focus {
border-color: var(--primary-color);
}
.vc-preview-textarea {
resize: vertical;
min-height: 52px;
}
.vc-preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vc-preview-btn {
width: 100%;
padding: 10px 0;
border-radius: 8px;
border: none;
background: var(--primary-color);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.15s;
}
.vc-preview-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -132,12 +132,16 @@ const VoiceLibrary: React.FC = () => {
ttsText,
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
ttsError,
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsEmotion,
setTtsLanguage,
setTtsOpen,
handleTtsSynthesize,
handleTtsSave,
@@ -363,6 +367,8 @@ const VoiceLibrary: React.FC = () => {
ttsText={ttsText}
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsEmotion={ttsEmotion}
ttsLanguage={ttsLanguage}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
@@ -374,6 +380,8 @@ const VoiceLibrary: React.FC = () => {
onTtsTextChange={setTtsText}
onTtsVoiceChange={setTtsVoiceId}
onTtsSpeedChange={setTtsSpeed}
onTtsEmotionChange={setTtsEmotion}
onTtsLanguageChange={setTtsLanguage}
onTtsSynthesize={handleTtsSynthesize}
onTtsSave={handleTtsSave}
/>
@@ -4,6 +4,8 @@ import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
import TextInputSection from "./tts-modal/TextInputSection"
import VoiceSelector from "./tts-modal/VoiceSelector"
import SpeedControl from "./tts-modal/SpeedControl"
import EmotionControl from "./tts-modal/EmotionControl"
import LanguageControl from "./tts-modal/LanguageControl"
import SynthesizeButton from "./tts-modal/SynthesizeButton"
import ErrorAlert from "./tts-modal/ErrorAlert"
import ResultPanel from "./tts-modal/ResultPanel"
@@ -14,6 +16,8 @@ const TtsModal: React.FC<TtsModalProps> = ({
ttsText,
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
ttsError,
@@ -23,6 +27,8 @@ const TtsModal: React.FC<TtsModalProps> = ({
onTextChange,
onVoiceChange,
onSpeedChange,
onEmotionChange,
onLanguageChange,
onSynthesize,
onSave,
}) => {
@@ -43,6 +49,16 @@ const TtsModal: React.FC<TtsModalProps> = ({
presetVoices={presetVoices}
clonedVoices={clonedVoices}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
}}
>
<EmotionControl emotion={ttsEmotion} onChange={onEmotionChange} />
<LanguageControl language={ttsLanguage} onChange={onLanguageChange} />
</div>
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
{ttsError && <ErrorAlert error={ttsError} />}
@@ -6,6 +6,7 @@ import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
import type { TtsStatus } from "./TtsModal"
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
import type { TtsEmotion, TtsLanguage } from "./tts-modal/constants"
import CloneModal from "@/components/voice/CloneModal"
import CloneDetailModal from "./CloneDetailModal"
import UploadVoiceModal from "./UploadVoiceModal"
@@ -42,6 +43,8 @@ export interface VoiceModalsProps {
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsEmotion: TtsEmotion
ttsLanguage: TtsLanguage
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
@@ -52,6 +55,8 @@ export interface VoiceModalsProps {
onTtsTextChange: (text: string) => void
onTtsVoiceChange: (id: string) => void
onTtsSpeedChange: (speed: number) => void
onTtsEmotionChange: (emotion: TtsEmotion) => void
onTtsLanguageChange: (language: TtsLanguage) => void
onTtsSynthesize: () => void
onTtsSave: () => void
}
@@ -80,6 +85,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsText,
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
ttsError,
@@ -89,6 +96,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
onTtsTextChange,
onTtsVoiceChange,
onTtsSpeedChange,
onTtsEmotionChange,
onTtsLanguageChange,
onTtsSynthesize,
onTtsSave,
}) => {
@@ -129,6 +138,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsText={ttsText}
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsEmotion={ttsEmotion}
ttsLanguage={ttsLanguage}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
@@ -138,6 +149,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
onTextChange={onTtsTextChange}
onVoiceChange={onTtsVoiceChange}
onSpeedChange={onTtsSpeedChange}
onEmotionChange={onTtsEmotionChange}
onLanguageChange={onTtsLanguageChange}
onSynthesize={onTtsSynthesize}
onSave={onTtsSave}
/>
@@ -0,0 +1,47 @@
import React from "react"
import { TTS_EMOTION_OPTIONS, type TtsEmotion } from "./constants"
interface EmotionControlProps {
emotion: TtsEmotion
onChange: (emotion: TtsEmotion) => void
}
/** 情绪选择下拉 */
const EmotionControl: React.FC<EmotionControlProps> = ({ emotion, onChange }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
情绪
</div>
<select
value={emotion}
onChange={(e) => onChange(e.target.value as TtsEmotion)}
style={{
width: "100%",
padding: "7px 10px",
borderRadius: 6,
border: "1px solid var(--border-color, #e5e7eb)",
background: "var(--bg-primary, #fff)",
color: "var(--text-primary)",
fontSize: 13,
outline: "none",
cursor: "pointer",
}}
>
{TTS_EMOTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)
}
export default EmotionControl
@@ -0,0 +1,47 @@
import React from "react"
import { TTS_LANGUAGE_OPTIONS, type TtsLanguage } from "./constants"
interface LanguageControlProps {
language: TtsLanguage
onChange: (language: TtsLanguage) => void
}
/** 语言选择下拉 */
const LanguageControl: React.FC<LanguageControlProps> = ({ language, onChange }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
语言
</div>
<select
value={language}
onChange={(e) => onChange(e.target.value as TtsLanguage)}
style={{
width: "100%",
padding: "7px 10px",
borderRadius: 6,
border: "1px solid var(--border-color, #e5e7eb)",
background: "var(--bg-primary, #fff)",
color: "var(--text-primary)",
fontSize: 13,
outline: "none",
cursor: "pointer",
}}
>
{TTS_LANGUAGE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)
}
export default LanguageControl
@@ -0,0 +1,22 @@
/** TTS 情绪选项(对齐后端 CosyVoice 支持:natural/excited/calm/friendly) */
export const TTS_EMOTION_OPTIONS = [
{ value: "natural", label: "自然" },
{ value: "excited", label: "兴奋" },
{ value: "calm", label: "沉稳" },
{ value: "friendly", label: "亲切" },
] as const
export type TtsEmotion = (typeof TTS_EMOTION_OPTIONS)[number]["value"]
/** TTS 语言选项 */
export const TTS_LANGUAGE_OPTIONS = [
{ value: "zh-CN", label: "中文" },
{ value: "en", label: "英文" },
{ value: "ja", label: "日文" },
{ value: "ko", label: "韩文" },
] as const
export type TtsLanguage = (typeof TTS_LANGUAGE_OPTIONS)[number]["value"]
export const DEFAULT_TTS_EMOTION: TtsEmotion = "natural"
export const DEFAULT_TTS_LANGUAGE: TtsLanguage = "zh-CN"
@@ -1,5 +1,6 @@
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import type { TtsClonedVoiceOption } from "./VoiceSelector"
import type { TtsEmotion, TtsLanguage } from "./constants"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -8,6 +9,8 @@ export interface TtsModalProps {
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsEmotion: TtsEmotion
ttsLanguage: TtsLanguage
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
@@ -18,6 +21,8 @@ export interface TtsModalProps {
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onEmotionChange: (emotion: TtsEmotion) => void
onLanguageChange: (language: TtsLanguage) => void
onSynthesize: () => void
onSave: () => void
}
@@ -4,6 +4,12 @@ import { message } from "antd"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { type PresetVoiceDisplay } from "../types"
import type { TtsClonedVoiceOption } from "../components/tts-modal/VoiceSelector"
import {
DEFAULT_TTS_EMOTION,
DEFAULT_TTS_LANGUAGE,
type TtsEmotion,
type TtsLanguage,
} from "../components/tts-modal/constants"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -29,6 +35,8 @@ export function useTtsSynthesize({
const [ttsText, setTtsText] = useState("")
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsSpeed, setTtsSpeed] = useState(1.0)
const [ttsEmotion, setTtsEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
const [ttsLanguage, setTtsLanguage] = useState<TtsLanguage>(DEFAULT_TTS_LANGUAGE)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
@@ -51,6 +59,8 @@ export function useTtsSynthesize({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
emotion: ttsEmotion,
language: ttsLanguage,
})
setTtsJobId(resp.job_id)
@@ -81,7 +91,7 @@ export function useTtsSynthesize({
setTtsStatus("error")
setTtsError(msg)
}
}, [ttsText, ttsVoiceId, ttsSpeed])
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsLanguage])
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
@@ -103,6 +113,8 @@ export function useTtsSynthesize({
setTtsText("")
setTtsVoiceId("")
setTtsSpeed(1.0)
setTtsEmotion(DEFAULT_TTS_EMOTION)
setTtsLanguage(DEFAULT_TTS_LANGUAGE)
setTtsStatus("idle")
setTtsAudioUrl(null)
setTtsError(null)
@@ -132,6 +144,8 @@ export function useTtsSynthesize({
ttsText,
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsLanguage,
ttsJobId,
ttsStatus,
ttsAudioUrl,
@@ -143,6 +157,8 @@ export function useTtsSynthesize({
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsEmotion,
setTtsLanguage,
setTtsOpen,
// Actions
handleTtsSynthesize,
@@ -1,7 +1,7 @@
/**
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750)
*
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
* useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑:
* - 有配音时 video 保持 muted(素材原声不与配音混音)
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
@@ -321,8 +321,14 @@ def build_subtitles_from_plan(
has_any = False
# 1. 标题
title_cfg = plan_config.get("title_config") or {}
# 1. 标题(#1901 统一读 "title",兼容老数据 "title_config")
title_cfg = plan_config.get("title") or {}
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
_alt = plan_config.get("title_config") or {}
if isinstance(_alt, dict):
title_cfg = _alt
if not isinstance(title_cfg, dict):
title_cfg = {}
if isinstance(title_cfg, dict):
title_text = str(title_cfg.get("text", ""))
title_enabled = title_cfg.get("enabled", True)
@@ -572,7 +572,12 @@ class UnifiedRenderService:
ASS 文件路径,没有字幕时返回 None
"""
config = self.plan.config or {}
# #1901 统一读 "title",兼容老数据 "title_config"
title_cfg = config.get("title", {}) or {}
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
_alt = config.get("title_config") or {}
if isinstance(_alt, dict):
title_cfg = _alt
if not isinstance(title_cfg, dict):
title_cfg = {}
subtitle_cfg = config.get("subtitle", {}) or {}
+812
View File
@@ -0,0 +1,812 @@
# 会员制 + 积分方案设计文档(v2)
> **Issue**: [#1895](https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/issues/1895)
> **版本**: v2.0
> **状态**: 方案设计(待确认)
> **创建时间**: 2026-09-14
> **更新**: 2026-09-14 — 基于用户确认方案重写,简化为两档会员 + 基于真实 AI 调用成本定价
---
## 一、方案概述
### 1.1 核心思路
**两档会员 + 积分消耗制**:
- **免费会员**:每天可免费混剪 2 条视频;其他 AI 功能可购买积分包使用(消耗按会员价 ×1.15)
- **付费会员**:解锁全部功能 + 积分购买折扣
### 1.2 设计原则
| 原则 | 说明 |
|------|------|
| **极简** | 只分免费/付费两档,不搞多梯度会员 |
| **成本驱动** | 积分定价严格基于真实 AI 调用成本,毛利率 60-75% |
| **可扩展** | 新增消耗场景只需注册,不改动核心逻辑 |
---
## 二、真实成本基准
> 数据来源:《小虾 SaaS AI 能力成本核算报告》(生产实测 + API 官方定价)
| 能力 | 调用方式 | 真实成本 |
|------|---------|----------|
| AI 配音(TTS) | 阿里云 cosyvoice-v3-flash | ¥0.024/分钟 |
| AI 数字人(口型同步) | 火山 MediaKit lip-sync | ¥1.028/分钟(含 TTS) |
| 智能混剪渲染 | 本地 FFmpeg(CPU) | ¥0.001/分钟 |
| 声音克隆训练 | 阿里云 voice-enrollment | 免费(合成才收费) |
| 声音克隆合成 | 同 cosyvoice TTS | ¥0.024/分钟 |
| AI 文案改写 / 标题生成 | 火山方舟 doubao-seed-1-6 | <¥0.001/条 |
| 抖音链接提取文案 | yt-dlp + 火山 ASR | ¥0.03/分钟 |
| AI 封面生成 | 本地 ffmpeg 抽帧 + PIL | <¥0.001/张 |
| OSS 存储 | 阿里云杭州标准 | ¥0.001/条/月 |
**典型场景成本:**
- 1 分钟基础混剪(TTS + FFmpeg + 封面 + 存储 1 月):**≈ ¥0.03/条**
- 1 分钟数字人口播(TTS + lip-sync + 封装):**≈ ¥1.03/条**(是混剪的 35 倍)
- 免费用户每天 2 条混剪:单活跃用户月成本 **≈ ¥2.3**(完全可承受)
---
## 三、会员等级设计
### 3.1 两档会员对照
| 权益项 | 免费会员 | 付费会员 |
|--------|---------|---------|
| **每日免费混剪** | 2 条/天(含 TTS + 渲染) | 不限次数 |
| **AI 数字人** | ✓ 可用(消耗×1.15) | ✓ 可用 |
| **声音克隆** | ✓ 可用(消耗×1.15) | ✓ 可用 |
| **AI 文案改写** | ✓ 可用(消耗×1.15) | ✓ 可用 |
| **AI 标题生成** | ✓ 可用(消耗×1.15) | ✓ 可用 |
| **抖音链接提取** | ✓ 可用(消耗×1.15) | ✓ 可用 |
| **AI 封面生成** | ✓ 可用(消耗×1.15) | ✓ 可用 |
| **导出分辨率** | 720p | 最高 1080p |
| **积分购买折扣** | 无 | 见下表 |
| **批量导出** | ✗ | ✓ |
| **多平台发布** | ✗ | ✓ |
| **去重检测报告** | ✗ | ✓ |
### 3.2 付费会员定价
| 套餐 | 价格 | 折合月价 | 积分购买折扣 |
|------|------|---------|-------------|
| 月卡 | ¥19.9/月 | ¥19.9 | 9 折 |
| 季卡 | ¥39.9/季 | ≈ ¥13.3 | 8.7 折 |
| 年卡 | ¥159/年 | ≈ ¥13.25 | 8 折 |
> 年卡和季卡的核心优惠体现在**积分折扣更低**,适合长期高频使用的用户。
### 3.3 与现有代码的映射
**废弃** `subscription.py` 中的 4 档 `PLAN_QUOTAS`(free/standard/pro/enterprise),替换为:
```python
MEMBER_TIERS = {
"free": {
"name": "免费会员",
"daily_free_clips": 2, # 每日免费混剪数
"max_resolution": "720p",
"monthly_credits": 0,
"points_discount": 1.0, # 无折扣
"points_multiplier": 1.15, # 积分消耗上浮 15%
},
"paid": {
"name": "付费会员",
"daily_free_clips": -1, # 不限
"max_resolution": "1080p",
"monthly_credits": 0,
"points_discount": 1.0, # 默认月卡 9 折通过购买折扣体现
"points_multiplier": 1.0,
},
}
```
付费会员的积分购买折扣通过订单系统实现,不在 `MEMBER_TIERS` 中硬编码:
```python
MEMBER_DISCOUNT = {
"monthly": 0.9, # 月卡 9 折
"quarterly": 0.87, # 季卡 8.7 折
"yearly": 0.8, # 年卡 8 折
}
```
---
## 四、积分体系设计
### 4.1 积分获取
| 获取方式 | 说明 | 频率 |
|----------|------|------|
| **积分包充值** | 用户按需购买,付费会员享受折扣 | 随时 |
| **任务奖励** | 注册送 50、首次生成视频送 20、邀请好友送 30/人(月上限 10 人) | 一次性/周期性 |
### 4.2 积分包定价
积分定价核心:**1 积分 ≈ ¥0.10(会员价)**,免费用户同操作消耗 ×1.15 向上取整。
| 积分包 | 积分数量 | 会员价 | 折合单价 | 免费用户等量价格 |
|--------|----------|--------|---------|----------------|
| 体验包 | 100 | ¥9.9 | ¥0.099/积分 | ¥10.89 |
| 基础包 | 500 | ¥39 | ¥0.078/积分 | ¥42.9 |
| 专业包 | 2,000 | ¥129 | ¥0.065/积分 | ¥141.9 |
> 简化为 3 档,覆盖轻度、中度、重度用户。
### 4.3 积分消耗场景清单
| 功能模块 | 消耗场景 | 会员消耗 | 免费用户消耗(×1.15↑) | 真实成本 | 毛利率 |
|----------|----------|---------|----------------------|----------|--------|
| **AI 配音** | 生成 1 分钟配音 | 1 积分 | 2 积分 | ¥0.024 | ~76% |
| **智能混剪** | 生成 1 条视频(≤30s) | 3 积分 | 4 积分 | ¥0.03 | >85% |
| **智能混剪(加长)** | 每增加 30s | +1 积分 | +2 积分 | — | — |
| **AI 数字人** | 生成 1 分钟口播视频 | 15 积分 | 18 积分 | ¥1.03 | ~30% |
| **声音克隆训练** | 训练一个声音模型 | **免费** | **免费** | ¥0 | — |
| **声音克隆合成** | 用克隆声音生成 1 分钟配音 | 1 积分 | 2 积分 | ¥0.024 | ~76% |
| **抖音链接提取** | 提取 1 次文案 | 1 积分 | 2 积分 | ¥0.03 | ~70% |
| **AI 改写文案** | 改写 1 次 | 1 积分 | 2 积分 | <¥0.001 | ~99% |
| **AI 标题生成** | 生成 1 次(≤10 条) | 1 积分 | 2 积分 | <¥0.001 | ~99% |
| **AI 封面生成** | 生成 1 张 | 1 积分 | 2 积分 | <¥0.001 | ~99% |
> **免费用户规则**:每天可免费混剪 2 条视频(含 TTS),不消耗积分。使用其他 AI 功能需购买积分包,积分消耗按会员价的 1.15 倍向上取整计算。
### 4.4 积分有效期
- 用户付费购买的积分:**永久有效**,不清零
- 任务奖励赠送的积分:永久有效
---
## 五、数据库设计
### 5.1 users 表新增字段
```sql
-- 在现有 users 表上新增字段
ALTER TABLE users ADD COLUMN is_member BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN member_type VARCHAR(20) DEFAULT NULL; -- monthly / quarterly / yearly
ALTER TABLE users ADD COLUMN member_expires_at TIMESTAMP DEFAULT NULL;
ALTER TABLE users ADD COLUMN points_balance INTEGER NOT NULL DEFAULT 0;
-- 保留但可废弃的字段(兼容期)
-- subscription_plan → 迁移期间映射为 is_member + member_type
-- subscription_status → 迁移期间保留
-- max_projects / max_storage_gb → 不再限制,保留字段但不再使用
```
### 5.2 积分账户表 `points_accounts`
每个用户一条记录,记录余额和累计值。
```sql
CREATE TABLE IF NOT EXISTS points_accounts (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
balance INTEGER NOT NULL DEFAULT 0, -- 当前可用积分
total_earned INTEGER NOT NULL DEFAULT 0, -- 累计获得
total_spent INTEGER NOT NULL DEFAULT 0, -- 累计消耗
total_purchased INTEGER NOT NULL DEFAULT 0, -- 累计购买
total_gifted INTEGER NOT NULL DEFAULT 0, -- 累计赠送(任务奖励)
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_points_accounts_user ON points_accounts(user_id);
```
### 5.3 积分流水表 `points_transactions`
每笔积分变动一条记录,只追加不修改,保证审计完整性。
```sql
CREATE TABLE IF NOT EXISTS points_transactions (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id VARCHAR(36) NOT NULL REFERENCES points_accounts(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL, -- earn(获得) / spend(消耗) / refund(退还)
source VARCHAR(50) NOT NULL, -- recharge / task_reward / ai_voice / ai_digital_human / ai_video / ai_rewrite / ai_title / ai_cover / douyin_extract / voice_clone
amount INTEGER NOT NULL, -- 变动数量(正数)
balance_after INTEGER NOT NULL, -- 变动后余额
description VARCHAR(255) DEFAULT '',
ref_id VARCHAR(100) DEFAULT '', -- 关联业务 ID(订单号、任务 ID 等)
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_points_tx_user ON points_transactions(user_id);
CREATE INDEX idx_points_tx_type ON points_transactions(type);
CREATE INDEX idx_points_tx_source ON points_transactions(source);
CREATE INDEX idx_points_tx_created ON points_transactions(created_at);
```
### 5.4 积分订单表 `points_orders`
记录积分充值订单。
```sql
CREATE TABLE IF NOT EXISTS points_orders (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
package_name VARCHAR(50) NOT NULL, -- 体验包/基础包/专业包
points_amount INTEGER NOT NULL, -- 积分数量
price_cents INTEGER NOT NULL, -- 支付金额(分)
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
discount REAL NOT NULL DEFAULT 1.0, -- 享受的折扣
original_price_cents INTEGER NOT NULL, -- 原价(分)
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending / paid / failed / refunded
payment_method VARCHAR(50), -- wechat_pay / alipay
payment_id VARCHAR(100), -- 第三方支付流水号
paid_at TIMESTAMP,
expire_at TIMESTAMP, -- 未支付自动过期时间
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_points_orders_user ON points_orders(user_id);
CREATE INDEX idx_points_orders_status ON points_orders(status);
```
### 5.5 每日免费混剪计数 `daily_usage_records`
```sql
CREATE TABLE IF NOT EXISTS daily_usage_records (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
usage_date DATE NOT NULL, -- 使用日期(UTC+8)
usage_type VARCHAR(50) NOT NULL, -- free_clip(免费混剪)
count INTEGER NOT NULL DEFAULT 0, -- 当日已使用次数
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id, usage_date, usage_type)
);
CREATE INDEX idx_daily_usage_user_date ON daily_usage_records(user_id, usage_date);
```
> **Redis 辅助**:用 Redis `INCR` 做实时计数(key: `daily_usage:{user_id}:{date}:{usage_type}`),TTL 48 小时自动过期;DB 作为持久化兜底,定期同步。
### 5.6 表关系图
```
users (1) ──── (1) points_accounts
│
└── (1:N) points_transactions
│
└── ref_id → generation_tasks / points_orders / ...
users (1) ──── (1:N) points_orders
users (1) ──── (1:N) daily_usage_records
```
---
## 六、API 设计
### 6.1 积分账户
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/points/balance` | 查询积分余额 + 会员状态 |
| GET | `/api/v1/points/transactions` | 积分流水(分页 + 筛选) |
#### `GET /api/v1/points/balance` 响应
```json
{
"balance": 580,
"total_earned": 1200,
"total_spent": 620,
"is_member": true,
"member_type": "yearly",
"member_expires_at": "2027-09-14T00:00:00+08:00"
}
```
#### `GET /api/v1/points/transactions` 请求参数
```
?page=1&page_size=20&type=spend&source=ai_voice&start_date=2026-09-01&end_date=2026-09-30
```
### 6.2 积分充值
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/points/packages` | 查询可购买的积分包列表 |
| POST | `/api/v1/points/recharge` | 创建充值订单 |
| POST | `/api/v1/points/payment-callback` | 支付回调(内部) |
#### `GET /api/v1/points/packages` 响应
```json
{
"packages": [
{
"id": "starter_pack",
"name": "体验包",
"points": 100,
"price": 990,
"discounted_price_for_monthly": 891,
"discounted_price_for_quarterly": 861,
"discounted_price_for_yearly": 792
},
{
"id": "basic_pack",
"name": "基础包",
"points": 500,
"price": 3900,
"discounted_price_for_monthly": 3510,
"discounted_price_for_quarterly": 3393,
"discounted_price_for_yearly": 3120
},
{
"id": "pro_pack",
"name": "专业包",
"points": 2000,
"price": 12900,
"discounted_price_for_monthly": 11610,
"discounted_price_for_quarterly": 11223,
"discounted_price_for_yearly": 10320
}
],
"user_discount": "yearly",
"unit_price_yuan": 0.10
}
```
> 价格单位为**分**(避免浮点精度问题)。折扣根据用户当前会员类型自动计算。
### 6.3 积分消耗(内部服务接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/points/check` | 消费前余额检查 |
| POST | `/api/v1/points/deduct` | 消费扣减(事务性) |
| POST | `/api/v1/points/refund` | 消费退还(业务失败时) |
#### `POST /api/v1/points/check` 请求
```json
{
"scene_key": "ai_digital_human",
"duration_minutes": 1,
"user_id": "xxx"
}
```
#### `POST /api/v1/points/check` 响应
```json
{
"allowed": true,
"required_points": 15,
"current_balance": 580,
"remaining_after": 565,
"is_free_quota": false
}
```
### 6.4 每日免费额度
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/usage/daily` | 查询今日免费额度使用情况 |
#### `GET /api/v1/usage/daily` 响应
```json
{
"free_clips_used": 1,
"free_clips_limit": 2,
"free_clips_remaining": 1,
"reset_at": "2026-09-15T00:00:00+08:00"
}
```
### 6.5 会员订阅(改造现有接口)
| 方法 | 路径 | 说明 | 改动 |
|------|------|------|------|
| GET | `/api/v1/subscription/current` | 当前会员信息 | 增加积分余额、会员类型 |
| POST | `/api/v1/subscription/subscribe` | 开通/续费会员 | 替换原 change-plan |
| POST | `/api/v1/subscription/cancel` | 取消自动续费 | 保留 |
| POST | `/api/v1/subscription/payment-callback` | 支付回调 | 改造 |
#### `POST /api/v1/subscription/subscribe` 请求
```json
{
"member_type": "yearly",
"payment_method": "wechat_pay"
}
```
### 6.6 消耗规则查询
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/points/rules` | 查询所有积分消耗规则 |
```json
{
"rules": [
{ "scene_key": "ai_voice", "scene_name": "AI 配音", "points_per_use": 1, "unit": "分钟" },
{ "scene_key": "ai_video", "scene_name": "智能混剪", "points_per_use": 3, "unit": "条", "extra_per_30s": 1 },
{ "scene_key": "ai_digital_human", "scene_name": "AI 数字人", "points_per_use": 15, "unit": "分钟" },
{ "scene_key": "voice_clone_train", "scene_name": "声音克隆训练", "points_per_use": 0 },
{ "scene_key": "voice_clone_synth", "scene_name": "声音克隆合成", "points_per_use": 1, "unit": "分钟" },
{ "scene_key": "douyin_extract", "scene_name": "抖音链接提取", "points_per_use": 1, "unit": "次" },
{ "scene_key": "ai_rewrite", "scene_name": "AI 改写文案", "points_per_use": 1, "unit": "次" },
{ "scene_key": "ai_title", "scene_name": "AI 标题生成", "points_per_use": 1, "unit": "次" },
{ "scene_key": "ai_cover", "scene_name": "AI 封面生成", "points_per_use": 1, "unit": "张" }
],
"free_user_multiplier": 1.15,
"note": "免费用户消耗 = 会员消耗 × 1.15,向上取整"
}
```
---
## 七、前端页面改造要点
### 7.1 会员购买页(改造 Plans.tsx)
**现状**:写死 4 档(free/standard/pro/enterprise),与后端不一致。
**改造为**:
```
┌──────────────────────────────────────────────┐
│ 选择适合您的方案 │
├────────────────────┬─────────────────────────┤
│ 免费会员 │ 付费会员 ★ 推荐 │
│ ¥0 │ ¥19.9/月 │
│ │ ¥39.9/季(省33%) │
│ ✓ 每日2条免费混剪 │ ¥159/年(省33%) │
│ ✓ 720p 导出 │ │
│ ✓ AI功能(积分×1.15)│ ✓ 无限混剪 │
│ │ ✓ 全部AI功能(标准积分) │
│ │ ✓ 积分购买最高 8 折 │
│ │ ✓ 最高 1080p 导出 │
│ │ │
│ [当前方案] │ [立即开通] │
├────────────────────┴─────────────────────────┤
│ 💰 积分充值(付费会员专享折扣) │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │100积分 │ │500积分 │ │2000积分│ │
│ │ ¥9.9 │ │ ¥39 │ │ ¥129 │ │
│ └────────┘ └────────┘ └────────┘ │
└──────────────────────────────────────────────┘
```
**改动要点**:
- 移除 4 档卡片,改为左右对比布局
- 付费会员区增加月卡/季卡/年卡切换
- 底部增加积分包快捷购买区
### 7.2 新增积分中心页 `/app/points`
```
┌──────────────────────────────────────────────┐
│ 积分中心 │
├──────────────────────────────────────────────┤
│ │
│ 💎 580 积分 会员到期: 2027-09-14 │
│ 本月获得 +200 本月消耗 -120 │
│ │
├──────────────────────────────────────────────┤
│ [积分明细] [充值记录] [消耗规则] │
│ ───────── │
│ 时间 类型 场景 数量 余额 │
│ 09-14 10:30 消耗 AI配音 -1 579 │
│ 09-14 09:15 消耗 智能混剪 -5 580 │
│ 09-01 00:00 获得 任务奖励 +50 585 │
│ 08-28 14:20 获得 充值+500 +500 385 │
│ ... │
│ │
└──────────────────────────────────────────────┘
```
### 7.3 Header 积分徽章
- 位置:顶部导航栏用户头像左侧
- 显示:💎 + 积分余额
- 余额 < 10 时变橙色警告
- 点击展开快捷面板:余额、本月消耗、充值入口
### 7.4 功能入口消耗提示
在每个 AI 功能操作按钮旁显示:
```
[生成配音] 💎 -1 积分
[生成视频] 💎 -5 积分(约1分钟)
[数字人口播] 💎 -15 积分/分钟
```
免费用户看到 AI 功能时显示消耗提示(×1.15 价格),并提示"开通会员享更低积分消耗"。
### 7.5 免费额度提示
免费用户在工作台顶部显示:
```
今日免费混剪:已用 1/2 条 [升级会员享无限混剪 →]
```
### 7.6 前端文件改动清单
| 文件 | 改动类型 | 说明 |
|------|---------|------|
| `Plans.tsx` | 重写 | 两档会员 + 积分包购买 |
| `pages/points/Center.tsx` | 新增 | 积分中心主页 |
| `pages/points/Transactions.tsx` | 新增 | 积分明细 |
| `pages/points/Packages.tsx` | 新增 | 积分包充值 |
| `components/common/PointsBadge/` | 新增 | Header 积分徽章 |
| `components/common/PointsCost/` | 新增 | 消耗提示组件 |
| `api/points.ts` | 新增 | 积分相关 API 封装 |
| `api/subscription.ts` | 改造 | 对接新会员接口 |
| `store/pointsStore.ts` | 新增 | 积分状态管理 |
---
## 八、配额检查中间件设计
### 8.1 整体流程
```
用户请求 → API Route
│
├─ 1. 检查会员状态(is_member + member_expires_at)
│
├─ 2. 免费混剪额度检查(仅混剪场景)
│ ├─ 免费用户 + 混剪功能 → 检查每日免费额度(Redis)
│ │ ├─ 未超限 → 放行,不扣积分
│ │ └─ 已超限 → 检查积分余额,按 ×1.15 扣减
│ └─ 付费用户 + 混剪功能 → 不限次数,不扣免费额度
│
├─ 3. 积分扣费检查(所有 AI 功能)
│ ├─ 免费用户:消耗 = 基础积分 × 1.15(向上取整)
│ └─ 付费用户:消耗 = 基础积分(享受会员价)
│ ├─ 余额充足 → 事务扣减 → 放行
│ └─ 余额不足 → 返回 402,提示充值
│
└─ 4. 业务执行失败 → 自动退还积分
```
### 8.2 装饰器实现
```python
# packages/middleware/points_gate.py
from functools import wraps
from fastapi import HTTPException
def points_gate(scene_key: str, per_unit: int = None, unit_field: str = None):
"""
AI 功能入口的积分扣费装饰器
Args:
scene_key: 消耗场景标识
per_unit: 每单位消耗积分(固定值时直接传)
unit_field: 动态计算时,从 request 中取数量的字段名
如 "duration_minutes" 表示按时长计费
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# 提取用户
current_user = _extract_user(args, kwargs)
request = _extract_request(args, kwargs)
# 计算消耗量
if per_unit is not None:
base_points = per_unit
elif unit_field is not None:
units = getattr(request, unit_field, 1)
base_points = get_scene_points(scene_key) * units
else:
base_points = get_scene_points(scene_key)
# 混剪功能:先检查免费额度
if scene_key == "free_clip":
if check_and_incr_daily_usage(current_user.user.id, "free_clip"):
# 免费额度内,不扣积分
kwargs["_points_deducted"] = 0
return await func(*args, **kwargs)
# 免费额度用完,继续走积分扣费逻辑
# 根据会员类型计算实际消耗
is_member = current_user.user.is_member
if is_member:
total_points = base_points # 会员标准价
else:
# 免费用户消耗 ×1.15,向上取整
import math
total_points = math.ceil(base_points * 1.15)
# 检查并扣减积分
points_service = get_points_service()
result = points_service.check_and_deduct(
user_id=current_user.user.id,
scene_key=scene_key,
amount=total_points,
)
if not result.success:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {total_points} 积分,当前余额 {result.balance}",
"recharge_url": "/app/points/recharge"
}
)
kwargs["_points_deduct_id"] = result.transaction_id
kwargs["_points_deducted"] = total_points
try:
return await func(*args, **kwargs)
except Exception as e:
# 业务失败,退还积分
points_service.refund(
user_id=current_user.user.id,
transaction_id=result.transaction_id,
reason=f"业务执行失败: {scene_key}"
)
raise
return wrapper
return decorator
```
### 8.3 每日免费额度检查(Redis)
```python
# packages/middleware/daily_usage.py
import redis
from datetime import datetime, timezone, timedelta
# Redis key: daily_usage:{user_id}:{date}:{usage_type}
# TTL: 48 小时自动过期
def check_and_incr_daily_usage(user_id: str, usage_type: str, limit: int = 2) -> bool:
"""
检查并递增每日使用次数
返回 True 表示允许,False 表示已超限
"""
tz = timezone(timedelta(hours=8))
today = datetime.now(tz).strftime("%Y%m%d")
key = f"daily_usage:{user_id}:{today}:{usage_type}"
r = get_redis_client()
current = r.get(key)
if current and int(current) >= limit:
return False
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, 48 * 3600) # 48 小时过期
results = pipe.execute()
# 同步到 DB(异步或定时任务)
sync_daily_usage_to_db(user_id, today, usage_type)
return True
```
### 8.4 与现有 QuotaChecker 的集成
现有 `QuotaChecker` 保留,负责检查存储、并发等基础配额。新增 `PointsGate` 负责积分扣费。两者在中间件层协作:
```python
# 统一中间件入口
async def unified_quota_check(user, scene_key, **kwargs):
# Step 1: 基础配额(存储、并发等)
plan = "paid" if user.is_member else "free"
base_check = quota_checker.check_multiple(plan, {
QuotaDimension.STORAGE_GB.value: get_used_storage(user.id),
QuotaDimension.MAX_CONCURRENT.value: get_concurrent_count(user.id),
})
for result in base_check:
if not result.allowed:
raise QuotaExceededError(result)
# Step 2: 积分扣费
await points_gate_check(user, scene_key, **kwargs)
```
---
## 九、支付接入
### 9.1 支付渠道
| 渠道 | 优先级 | 说明 |
|------|--------|------|
| **微信支付** | P0(首期) | 覆盖绝大多数用户 |
| **支付宝** | P1(后续) | 二期接入 |
### 9.2 支付流程
```
用户选择套餐/积分包
→ POST /api/v1/subscription/subscribe 或 /api/v1/points/recharge
→ 后端创建订单,调用微信支付统一下单 API
→ 返回支付参数(prepay_id / code_url)
→ 前端展示二维码 or 调起支付
→ 用户支付完成
→ 微信回调 POST /api/v1/subscription/payment-callback
→ 验签 → 更新订单状态 → 发放积分/开通会员
```
### 9.3 发票
- 初期不做自助开票
- 用户有需求时通过客服人工处理
### 9.4 退款策略
- 虚拟商品原则上不退
- 特殊情况(如系统故障导致积分异常消耗)由客服人工处理
---
## 十、文件改动总览
| 类型 | 文件路径 | 说明 |
|------|----------|------|
| **新增** | `migrations/007_membership_points.sql` | 新建积分相关表 |
| **新增** | `packages/domain/points_service.py` | 积分核心服务 |
| **新增** | `packages/middleware/points_gate.py` | 积分扣费中间件 |
| **新增** | `packages/middleware/daily_usage.py` | 每日免费额度检查 |
| **新增** | `apps/api/app/api/routes/points.py` | 积分 API 路由 |
| **新增** | `apps/api/app/schemas/points.py` | 积分 Schema |
| **新增** | `apps/web/src/pages/points/Center.tsx` | 积分中心页 |
| **新增** | `apps/web/src/pages/points/Transactions.tsx` | 积分明细页 |
| **新增** | `apps/web/src/pages/points/Packages.tsx` | 积分包充值页 |
| **新增** | `apps/web/src/components/common/PointsBadge/` | Header 积分徽章 |
| **新增** | `apps/web/src/components/common/PointsCost/` | 消耗提示组件 |
| **新增** | `apps/web/src/api/points.ts` | 积分 API 封装 |
| **新增** | `apps/web/src/store/pointsStore.ts` | 积分状态管理 |
| **修改** | `packages/domain/quota.py` | 简化配额维度 |
| **修改** | `packages/adapters/sqlalchemy_impl/models.py` | 新增积分 Model + users 字段 |
| **修改** | `apps/api/app/api/routes/subscription.py` | 改造为两档会员 |
| **修改** | `apps/api/app/schemas/subscription.py` | 简化 Schema |
| **修改** | `apps/web/src/pages/subscription/Plans.tsx` | 两档会员 UI |
| **修改** | 各 AI 功能路由 | 添加 `@points_gate` 装饰器 |
---
## 十一、实施排期
| 阶段 | 内容 | 预估工时 |
|------|------|----------|
| P1 | 数据库迁移 + Model + Repository | 2 天 |
| P2 | PointsService 核心逻辑 + 每日额度 | 2 天 |
| P3 | 积分 API + 微信支付接入 | 3 天 |
| P4 | 中间件改造(各 AI 功能接入) | 2 天 |
| P5 | 前端页面改造 + 新增页面 | 4 天 |
| P6 | 集成测试 + 端到端测试 | 2 天 |
| **合计** | | **~15 天** |
---
## 十二、开放问题(待后续讨论)
| # | 问题 | 当前决策 |
|---|------|---------|
| 1 | 积分是否需要有效期? | **永久有效**(已确认) |
| 2 | 退款策略? | **虚拟商品不退**,特殊情况人工处理(已确认) |
| 3 | 企业版定制需求? | **暂不做**,后续按需(已确认) |
| 4 | 支付渠道优先级? | **微信优先**,支付宝二期(已确认) |
| 5 | 发票需求? | **暂不做**,后续按需(已确认) |
| 6 | 免费用户每日 2 条混剪是否限制分辨率? | 建议限制 720p |
---
*本方案基于真实 AI 调用成本核算,经用户确认后制定。待确认后即可进入开发阶段。*
+6 -5
View File
@@ -1,6 +1,6 @@
"""共享的句子时间戳计算工具 — 供 Celery TTS 任务和 /lipsync/tts-preview 同步接口复用.
- `_split_script_into_sentences`: 按标点分句(中英文逗号/句号/问号/感叹号/分号/换行)
- `split_script_into_sentences`: 按标点分句(中英文句号/问号/感叹号/分号/换行,不含逗号,与前端 sentences.ts 保持一致)
- `_estimate_sentence_timings_by_chars`: 按字数比例估算(静音检测失败时降级)
- `_probe_audio_duration`: ffprobe 读取音频时长
- `compute_sentence_timings`: 基于 ffmpeg silencedetect 精确计算每句起止时间
@@ -19,15 +19,16 @@ logger = logging.getLogger(__name__)
def split_script_into_sentences(script_text: str) -> list[str]:
"""按句号/问号/感叹号/分号/逗号/换行分句(与前端 SENTENCE_SPLIT_RE 一致).
"""按句号/问号/感叹号/分号/换行分句(与前端 sentences.ts 的 SENTENCE_SPLIT_RE 一致).
中文短视频文案习惯用「,」断小句(如"卖花的叫花无缺,卖姜的叫姜子牙"),
必须把逗号也纳入分隔符,否则多句文案会被识别成一整句,导致 B-roll 时间戳错位。
仅在句末标点(。!?!?;;)和换行处分句,**不再用逗号(,,)切分**。
按逗号切分会把连贯句子拆得过碎,导致 B-roll/口播画面按句插入时句数过多、
时长过短,效果不符合预期(Issue #1892)。
"""
text = (script_text or "").strip()
if not text:
return []
parts = re.split(r"[。!?!??!;;,,\n\r]+", text)
parts = re.split(r"[。!?!??!;;\n\r]+", text)
return [p.strip() for p in parts if p.strip()]
+49 -17
View File
@@ -36,14 +36,20 @@ CACHE_TAG_PRIMARY="${CACHE_TAG:-develop}"
API_IMAGE="xiaoxia-saas-api:$VERSION"
WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"
WEB_IMAGE="xiaoxia-saas-web:$VERSION"
API_LATEST="xiaoxia-saas-api:dev"
WORKER_LATEST="xiaoxia-saas-worker:dev"
API_DEV="xiaoxia-saas-api:dev"
WORKER_DEV="xiaoxia-saas-worker:dev"
WEB_DEV="xiaoxia-saas-web:dev"
# Registry 上的完整镜像名
# Registry 上的完整镜像名(SHA/版本 tag)
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:$VERSION"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:$VERSION"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$VERSION"
# Registry 上的 dev floating tag(仅 staging/develop 构建时推送,供 Watchtower 监听自动更新)
REGISTRY_API_DEV="${REGISTRY}/xiaoxia-saas-api:dev"
REGISTRY_WORKER_DEV="${REGISTRY}/xiaoxia-saas-worker:dev"
REGISTRY_WEB_DEV="${REGISTRY}/xiaoxia-saas-web:dev"
USE_CACHE=0
USE_PUSH=0
CACHE_WRITE=0
@@ -56,23 +62,34 @@ if docker buildx version >/dev/null 2>&1; then
docker buildx use default 2>/dev/null || true
fi
# ---- 是否需要推送 dev tag ----
# staging 构建 或 develop 分支构建时才推送 :dev tag,供 Watchtower 自动更新;
# production / release tag 构建不推送 dev tag,避免污染生产 tag 指向。
PUSH_DEV_TAG=0
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
if [ "$BUILD_ENV" = "staging" ] || [ "$BRANCH_NAME" = "develop" ]; then
PUSH_DEV_TAG=1
echo "Dev tag push: ENABLED (BUILD_ENV=$BUILD_ENV, BRANCH=$BRANCH_NAME) — Watchtower will pick up new :dev"
else
echo "Dev tag push: disabled (BUILD_ENV=$BUILD_ENV, BRANCH=$BRANCH_NAME)"
fi
# ---- 缓存读写策略(按分支隔离)----
# 默认只读不写,防止 feature 分支污染主缓存
# 只有 develop/main 分支才写回缓存
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
# 清理本地旧镜像
docker rmi -f "$API_IMAGE" "$API_LATEST" 2>/dev/null || true
docker rmi -f "$API_IMAGE" "$API_DEV" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/api.Dockerfile \
-t "$API_IMAGE" -t "$API_LATEST" \
-t "$API_IMAGE" -t "$API_DEV" \
--load \
.
else
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_DEV" .
fi
build_with_cache() {
@@ -122,22 +139,22 @@ build_with_cache() {
echo "=== Building API image ==="
build_with_cache "api" "infra/docker/api.Dockerfile" \
"--build-arg APP_VERSION=$VERSION"
docker tag "$API_IMAGE" "$API_LATEST"
docker tag "$API_IMAGE" "$API_DEV"
echo "=== Building Worker image ==="
# 清理本地旧镜像
docker rmi -f "$WORKER_IMAGE" "$WORKER_LATEST" 2>/dev/null || true
docker rmi -f "$WORKER_IMAGE" "$WORKER_DEV" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/worker.Dockerfile \
-t "$WORKER_IMAGE" -t "$WORKER_LATEST" \
-t "$WORKER_IMAGE" -t "$WORKER_DEV" \
--load \
.
else
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_DEV" .
fi
echo "=== Building Web image (with buildx cache) ==="
@@ -158,37 +175,52 @@ docker run --rm \
test -f apps/web/dist/index.html
# 清理本地旧镜像
docker rmi -f "$WEB_IMAGE" 2>/dev/null || true
docker rmi -f "$WEB_IMAGE" "$WEB_DEV" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-t "$WEB_IMAGE" \
-t "$WEB_IMAGE" -t "$WEB_DEV" \
--load \
.
else
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-t "$WEB_IMAGE" \
-t "$WEB_IMAGE" -t "$WEB_DEV" \
.
fi
# Push 到 Registry
if [ "$USE_PUSH" -eq 1 ]; then
echo "=== Pushing images to Registry ==="
echo "=== Pushing SHA/version-tagged images to Registry ==="
docker tag "$API_IMAGE" "$REGISTRY_API"
docker tag "$WORKER_IMAGE" "$REGISTRY_WORKER"
docker tag "$WEB_IMAGE" "$REGISTRY_WEB"
docker push "$REGISTRY_API"
docker push "$REGISTRY_WORKER"
docker push "$REGISTRY_WEB"
echo "All images pushed to $REGISTRY"
echo "SHA-tagged images pushed to $REGISTRY"
# ---- 额外推送 :dev floating tag(仅 staging/develop)----
# :dev tag 指向最新一次 develop/staging 构建,供 Watchtower 通过 WATCHTOWER_ROLLING_RESTART 等方式
# 监听镜像更新并自动 pull + restart;部署脚本 (ci_staging_deploy.sh) 仍使用 SHA tag 做确定性部署,
# dev tag 仅作为"最新 develop 成功构建"的可滚动标识,避免并发构建互相覆盖导致的不可重现问题。
if [ "$PUSH_DEV_TAG" -eq 1 ]; then
echo "=== Pushing :dev floating tags to Registry (for Watchtower auto-update) ==="
docker tag "$API_IMAGE" "$REGISTRY_API_DEV"
docker tag "$WORKER_IMAGE" "$REGISTRY_WORKER_DEV"
docker tag "$WEB_IMAGE" "$REGISTRY_WEB_DEV"
docker push "$REGISTRY_API_DEV"
docker push "$REGISTRY_WORKER_DEV"
docker push "$REGISTRY_WEB_DEV"
echo ":dev tags pushed to $REGISTRY"
fi
else
echo "Registry push skipped (no auth token available)"
fi
echo "=== Build complete ==="
docker images | grep "xiaoxia-saas" | grep "$VERSION" || true
docker images | grep "xiaoxia-saas" | grep -E "($VERSION|dev)" || true
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# ============================================
# 基础镜像同步脚本 - 从公共镜像源同步到私有ACR
# 用法:
# ACR_USERNAME=xxx ACR_PASSWORD=yyy bash scripts/ci/sync_base_images.sh
# ============================================
set -euo pipefail
ACR_REGISTRY="${ACR_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
ACR_USERNAME="${ACR_USERNAME:-}"
ACR_PASSWORD="${ACR_PASSWORD:-}"
SOURCE_PREFIX="${SOURCE_PREFIX:-docker.m.daocloud.io/library}"
# 需要同步的镜像列表 (源镜像名:tag => ACR目标名:tag)
IMAGES=(
"python:3.12-slim-bookworm"
"python:3.12-slim"
"node:20"
"nginx:alpine"
)
echo "============================================"
echo " 基础镜像同步到 ACR"
echo " ACR: $ACR_REGISTRY"
echo " 源: $SOURCE_PREFIX"
echo "============================================"
echo ""
# 登录 ACR
if [ -n "$ACR_PASSWORD" ] && [ -n "$ACR_USERNAME" ]; then
echo "登录 ACR..."
ACR_HOST=$(echo "$ACR_REGISTRY" | cut -d/ -f1)
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_HOST" -u "$ACR_USERNAME" --password-stdin
echo "ACR 登录成功"
echo ""
fi
success=0
failed=0
for image in "${IMAGES[@]}"; do
source_image="${SOURCE_PREFIX}/${image}"
target_image="${ACR_REGISTRY}/base/${image}"
echo "--- 同步: $image ---"
echo " 源: $source_image"
echo " 目标: $target_image"
# Pull 源镜像(带重试)
pulled=0
for attempt in 1 2 3; do
echo " Pull 尝试 $attempt/3..."
if docker pull "$source_image"; then
pulled=1
break
fi
echo " Pull 失败,5s 后重试..."
sleep 5
done
if [ "$pulled" -eq 0 ]; then
echo " ❌ Pull 失败: $image"
failed=$((failed + 1))
continue
fi
# Tag
docker tag "$source_image" "$target_image"
echo " Tag 完成"
# Push 到 ACR
pushed=0
for attempt in 1 2 3; do
echo " Push 尝试 $attempt/3..."
if docker push "$target_image"; then
pushed=1
break
fi
echo " Push 失败,5s 后重试..."
sleep 5
done
if [ "$pushed" -eq 1 ]; then
echo " ✅ 同步成功: $image"
success=$((success + 1))
else
echo " ❌ Push 失败: $image"
failed=$((failed + 1))
fi
echo ""
done
echo "============================================"
echo " 同步完成"
echo " 成功: $success"
echo " 失败: $failed"
echo "============================================"
if [ "$failed" -gt 0 ]; then
exit 1
fi
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
set -eu
echo "=== CI Validate: 代码质量与安全扫描 ==="
# --- 密钥检测 ---
echo ""
echo "=== [1/6] Secret detection (detect-secrets) ==="
python3 -m pip install -q detect-secrets
detect-secrets --version
detect-secrets scan \
--all-files \
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
--disable-plugin Base64HighEntropyString \
--disable-plugin HexHighEntropyString \
--disable-plugin BasicAuthDetector \
--disable-plugin KeywordDetector \
--disable-plugin IPPublicDetector \
> /tmp/secrets-scan.json 2>&1
FOUND=$(python3 -c "
import json
try:
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
results = data.get('results', {})
total = sum(len(v) for v in results.values())
print(total)
except Exception:
print('error')
")
echo "Secrets detected: $FOUND"
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
echo ""
echo "=== Secret details ==="
python3 -c "
import json
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
for fpath, items in data.get('results', {}).items():
for item in items:
line = item.get('line_number', '?')
stype = item.get('type', '?')
hashed = item.get('hashed_secret', '')[:16]
print(f' {fpath}:{line} [{stype}] {hashed}...')
"
echo ""
echo "ERROR: Potential secrets detected in code!"
exit 1
fi
echo "✅ Secret scan passed"
# --- 代码质量检查(全量,PR 和 push 统一标准)---
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
echo ""
echo "=== [2/6] Code quality checks (full scan) ==="
SCAN_MODE="full"
echo "Full scan mode"
python3 -m compileall -q alembic apps packages tests scripts
python3 -m black --check --fast alembic apps packages tests scripts
python3 -m isort --check-only alembic apps packages tests scripts
python3 -m ruff check apps packages tests --statistics
echo "✅ Code quality checks passed"
# --- Bandit 安全扫描(仅告警) ---
echo ""
echo "=== [3/6] Security scan (bandit, advisory only) ==="
set +e
bandit -r apps packages -q -ll
BANDIT_EXIT=$?
set -e
if [ "$BANDIT_EXIT" -ne 0 ]; then
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
else
echo "✅ Bandit security scan passed"
fi
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
echo ""
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
python3 -m pip install -q pip-audit
pip-audit --version
EXIT_CODE=0
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
if [ -f "$req_file" ]; then
echo "--- Scanning $req_file ---"
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
echo ""
fi
done
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
# --- Vulture 死代码检测(仅告警) ---
echo ""
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
set +e
python3 -m pip install -q vulture
vulture --version
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
echo ""
vulture apps packages scripts \
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
--min-confidence 70 \
2>&1 | sort -t'(' -k2 -rn | head -80
echo ""
echo "=== vulture scan summary ==="
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
echo "建议:定期人工审查高置信度(>=90%)条目"
set -e
# --- CI脚本语法校验 ---
echo ""
echo "=== [6/6] CI & shell scripts syntax validation ==="
SYNTAX_ERROR=0
# 检查所有 CI shell 脚本
for script in scripts/ci/*.sh; do
if [ -f "$script" ]; then
if ! bash -n "$script" 2>&1; then
echo "❌ 语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
# 检查所有 CI Python 脚本语法
for script in scripts/ci/*.py; do
if [ -f "$script" ]; then
if ! python3 -m py_compile "$script" 2>&1; then
echo "❌ Python语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
# 检查 .gitea/workflows 下的脚本(如果有)
for script in .gitea/workflows/*.sh; do
if [ -f "$script" ]; then
if ! bash -n "$script" 2>&1; then
echo "❌ 语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
if [ "$SYNTAX_ERROR" -ne 0 ]; then
echo "❌ CI脚本语法校验失败,见上方错误"
exit 1
fi
echo "✅ All CI scripts syntax OK"
echo ""
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
+52 -38
View File
@@ -170,16 +170,17 @@ rollback() {
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
--network-alias xiaoxia-api \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-e APP_VERSION="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
-e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--cpus 2 \
--memory 2g \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
@@ -196,26 +197,29 @@ rollback() {
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
--network-alias xiaoxia-worker \
--network-alias xiaoxia-api \
--env-file "$ENV_FILE" \
-e APP_ENV=production \
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
-e PYTHONPATH=/app:/app/apps/api:/app/packages \
-v "$GENERATED_DIR:/app/generated" \
-v "$LEGACY_ASSETS_DIR:/app/legacy-assets" \
-w /app/apps/worker \
--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\"" \
--cpus 2 \
--memory 3g \
--health-cmd "sh -c 'PYTHONPATH=/app:/app/apps/api:/app/packages celery -A worker_app.celery_app inspect ping -t 5 2>&1 | grep -q pong'" \
--health-interval 30s \
--health-timeout 10s \
--health-timeout 15s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$PREV_WORKER_IMAGE"
--health-start-period 60s \
--log-driver json-file --log-opt max-size=200m --log-opt max-file=5 \
"$PREV_WORKER_IMAGE" \
/usr/local/bin/entrypoint-worker.sh
else
echo "No previous Worker image to roll back to"
fi
@@ -230,12 +234,15 @@ rollback() {
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
--network-alias xiaoxia-web \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
-e APP_ENV=production \
-e API_BASE_URL=https://saas-api.xiaoxiajianji.com \
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
$LEGACY_VOLUME \
--restart unless-stopped \
--cpus 1 \
--memory 512m \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
@@ -373,12 +380,13 @@ docker run -d \
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
--network-alias xiaoxia-api \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
-e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
@@ -395,26 +403,29 @@ docker run -d \
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
--network-alias xiaoxia-worker \
--network-alias xiaoxia-api \
--env-file "$ENV_FILE" \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
-e PYTHONPATH=/app:/app/apps/api:/app/packages \
-v "$GENERATED_DIR:/app/generated" \
-v "$LEGACY_ASSETS_DIR:/app/legacy-assets" \
-w /app/apps/worker \
--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\"" \
--memory 3g \
--health-cmd "sh -c 'PYTHONPATH=/app:/app/apps/api:/app/packages celery -A worker_app.celery_app inspect ping -t 5 2>&1 | grep -q pong'" \
--health-interval 30s \
--health-timeout 10s \
--health-timeout 15s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$REGISTRY_WORKER" || rollback
--health-start-period 60s \
--log-driver json-file --log-opt max-size=200m --log-opt max-file=5 \
"$REGISTRY_WORKER" \
/usr/local/bin/entrypoint-worker.sh || rollback
# ---- 启动 Web ----
LEGACY_VOLUME=""
@@ -429,12 +440,15 @@ echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
--network-alias xiaoxia-web \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
-e APP_ENV=production \
-e API_BASE_URL=https://saas-api.xiaoxiajianji.com \
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
$LEGACY_VOLUME \
--restart unless-stopped \
--cpus 1 \
--memory 512m \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
@@ -487,7 +501,7 @@ docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=== Production deployment complete ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "API: http://127.0.0.1:8001"
echo "Web: http://127.0.0.1:3002"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
+3
View File
@@ -264,6 +264,9 @@ if [ -n "$REGISTRY_TOKEN" ]; then
fi
# ---- 并行 Pull 三个镜像 ----
# 注意:这里必须使用 IMAGE_TAG(commit SHA)做确定性部署,不要改成 :dev。
# :dev 是 floating tag,可能被并发构建覆盖,导致部署版本不可重现、回滚混乱。
# Watchtower 可监听 :dev 做非关键路径的自动同步;正式部署/回滚一律锚定 SHA。
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
+4 -4
View File
@@ -17,9 +17,9 @@
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
#
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167)
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 127.0.0.1,CI runner 在 staging 本机)
# STAGING_SSH_USER - SSH 用户名 (默认 root)
# STAGING_SSH_PORT - SSH 端口 (默认 22222)
# STAGING_SSH_PORT - SSH 端口 (默认 22)
# STAGING_SSH_KEY - SSH 私钥内容
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
#
@@ -40,9 +40,9 @@ HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
STAGING_SSH_HOST="${STAGING_SSH_HOST:-127.0.0.1}"
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
+299
View File
@@ -0,0 +1,299 @@
"""generation_common 公共服务辅助函数单元测试。
覆盖 query_voice_durations / writeback_edit_plan_config / collect_plan_segments /
resolve_latest_plan_by_template 四个下沉函数的主路径、边界与容错路径。
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
# ═══════════════════════════════════════════════════════════════════════════════
# query_voice_durations
# ═══════════════════════════════════════════════════════════════════════════════
class TestQueryVoiceDurations:
def _make_db_with_rows(self, rows):
"""构造 MagicMock db,query().filter().all() 返回 rows。"""
db = MagicMock()
db.query.return_value.filter.return_value.all.return_value = list(rows)
return db
def test_empty_input_returns_empty_list(self):
from app.services.generation_common import query_voice_durations
db = MagicMock()
assert query_voice_durations(db, []) == []
assert query_voice_durations(db, None) == []
db.query.assert_not_called()
def test_all_empty_or_falsy_ids_returns_zero_list(self):
from app.services.generation_common import query_voice_durations
db = MagicMock()
assert query_voice_durations(db, ["", None, ""]) == [0.0, 0.0, 0.0]
def test_normal_lookup_returns_durations_in_input_order(self):
from app.services.generation_common import query_voice_durations
db = self._make_db_with_rows([("v1", 3.5), ("v2", 7.2)])
result = query_voice_durations(db, ["v1", "v2", "v-missing"])
assert result == [3.5, 7.2, 0.0]
def test_duplicate_ids_returns_consistent_durations_preserves_order(self):
"""#1855:同配音 id 多次出现应返回相同时长,保持输入顺序/长度。"""
from app.services.generation_common import query_voice_durations
db = self._make_db_with_rows([("v1", 4.0)])
result = query_voice_durations(db, ["v1", "v1", "v1"])
assert result == [4.0, 4.0, 4.0]
def test_non_numeric_duration_coerced_to_zero(self):
from app.services.generation_common import query_voice_durations
db = self._make_db_with_rows([("v1", None), ("v2", "not-a-number"), ("v3", 2.0)])
result = query_voice_durations(db, ["v1", "v2", "v3"])
assert result == [0.0, 0.0, 2.0]
def test_db_exception_returns_zeros_and_logs(self, caplog):
from app.services.generation_common import query_voice_durations
db = MagicMock()
db.query.side_effect = RuntimeError("DB boom")
with caplog.at_level("WARNING"):
result = query_voice_durations(db, ["v1", "v2"])
assert result == [0.0, 0.0]
assert any("配音时长查询失败" in rec.message for rec in caplog.records)
# ═══════════════════════════════════════════════════════════════════════════════
# writeback_edit_plan_config
# ═══════════════════════════════════════════════════════════════════════════════
def _make_plan_model(config=None):
plan = MagicMock()
plan.config = config if config is not None else {}
return plan
class TestWritebackEditPlanConfig:
def test_empty_plan_id_returns_immediately(self):
from app.services.generation_common import writeback_edit_plan_config
db = MagicMock()
writeback_edit_plan_config("", "task1", None, db)
db.query.assert_not_called()
def test_plan_not_found_logs_and_returns(self, caplog):
from app.services.generation_common import writeback_edit_plan_config
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
with caplog.at_level("WARNING"):
writeback_edit_plan_config("p999", "task1", None, db)
db.commit.assert_not_called()
assert any("plan不存在" in rec.message for rec in caplog.records)
def test_writes_task_id_preserves_existing_config(self):
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"other": "keep-me"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "task-xyz", None, db)
assert plan.config["generation_task_id"] == "task-xyz"
assert plan.config["other"] == "keep-me"
assert "title" not in plan.config or not plan.config.get("title")
db.commit.assert_called_once()
def test_merges_title_without_title_change(self):
"""#1901: 写 'title' 字段,未变标题保留 cover。"""
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
assert plan.config["title"] == {"text": "old"}
# 旧 key 不应残留
assert "title_config" not in plan.config
# 标题未变 → cover 保留
assert plan.config.get("cover") == "x"
def test_merges_title_fallback_to_old_title_config_key(self):
"""#1901: 老数据存在 title_config(无 title)时,也能正确识别旧标题文字。"""
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
# 写入新 key "title",旧 key 被清除
assert plan.config["title"] == {"text": "old"}
assert "title_config" not in plan.config
assert plan.config.get("cover") == "x"
def test_title_change_clears_cover(self):
"""#1901: 标题变化时清 cover,新配置写到 'title'。"""
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "new-title"}, db)
assert "cover" not in plan.config
assert plan.config["title"] == {"text": "new-title"}
assert "title_config" not in plan.config
def test_title_config_normalizes_legacy_keys(self):
"""#1901: 写入时归一化 font_size/font_preset/font_color → size/font/color,与 worker 对齐。"""
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config(
"p1",
"t1",
{"text": "hi", "font_size": 32, "font_preset": "楷体", "font_color": "#ff0000", "bold": True},
db,
)
title = plan.config["title"]
assert title["text"] == "hi"
assert title["size"] == 32
assert title["font"] == "楷体"
assert title["color"] == "#ff0000"
# 原始 key 保留(方便调用方排查,但归一化后的 key 必须存在)
assert title["font_size"] == 32
def test_config_not_dict_treated_as_empty(self):
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model(config=None)
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "hi"}, db)
assert plan.config["generation_task_id"] == "t1"
assert plan.config["title"] == {"text": "hi"}
assert "title_config" not in plan.config
def test_exception_triggers_rollback_and_logs(self, caplog):
from app.services.generation_common import writeback_edit_plan_config
db = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = RuntimeError("fail")
with caplog.at_level("WARNING"):
writeback_edit_plan_config("p1", "t1", None, db)
db.rollback.assert_called_once()
assert any("回写plan.config异常" in rec.message for rec in caplog.records)
def test_exception_with_rollback_also_failing_is_safe(self, caplog):
"""外层异常后,db.rollback() 自己也抛异常时也不应中断(pass 兜底)。"""
from app.services.generation_common import writeback_edit_plan_config
db = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = RuntimeError("fail")
db.rollback.side_effect = RuntimeError("rollback boom")
with caplog.at_level("WARNING"):
# 不应抛出异常
writeback_edit_plan_config("p1", "t1", None, db)
assert any("回写plan.config异常" in rec.message for rec in caplog.records)
# ═══════════════════════════════════════════════════════════════════════════════
# collect_plan_segments
# ═══════════════════════════════════════════════════════════════════════════════
def _make_clip(asset_id, start, duration):
c = MagicMock()
c.asset_id = asset_id
c.start_time = start
c.duration = duration
return c
class TestCollectPlanSegments:
def test_empty_plan_returns_empty(self):
from app.services.generation_common import collect_plan_segments
repo = MagicMock()
repo.list_by_plan.return_value = []
assert collect_plan_segments("p1", repo) == {}
def test_single_page_collects_segments(self):
from app.services.generation_common import collect_plan_segments
repo = MagicMock()
repo.list_by_plan.side_effect = [
[_make_clip("a1", 0.0, 5.0), _make_clip("a1", 10.0, 3.0), _make_clip("a2", 2.0, 4.0)],
[],
]
segs = collect_plan_segments("p1", repo, page_size=500)
assert segs["a1"] == [(0.0, 5.0), (10.0, 13.0)]
assert segs["a2"] == [(2.0, 6.0)]
def test_pagination_walks_all_batches(self):
from app.services.generation_common import collect_plan_segments
repo = MagicMock()
page1 = [_make_clip("a1", 0.0, 1.0)] * 2
page2 = [_make_clip("a2", 0.0, 2.0)] * 2
page3 = [_make_clip("a3", 0.0, 1.0)] # short final batch → stop
repo.list_by_plan.side_effect = [page1, page2, page3]
segs = collect_plan_segments("p1", repo, page_size=2)
assert set(segs.keys()) == {"a1", "a2", "a3"}
assert repo.list_by_plan.call_count == 3
def test_skips_zero_or_negative_duration_clips(self):
from app.services.generation_common import collect_plan_segments
repo = MagicMock()
repo.list_by_plan.side_effect = [
[_make_clip(None, 0.0, 5.0), _make_clip("a1", 0.0, 0.0), _make_clip("a1", 1.0, -1.0)],
[],
]
assert collect_plan_segments("p1", repo) == {}
# ═══════════════════════════════════════════════════════════════════════════════
# resolve_latest_plan_by_template
# ═══════════════════════════════════════════════════════════════════════════════
class TestResolveLatestPlanByTemplate:
@pytest.mark.parametrize("tid", ["", None, " "])
def test_empty_template_returns_none(self, tid):
from app.services.generation_common import resolve_latest_plan_by_template
db = MagicMock()
assert resolve_latest_plan_by_template(db, template_id=tid, user_id="u1") is None
db.query.assert_not_called()
def test_returns_latest_plan_id(self):
from app.services.generation_common import resolve_latest_plan_by_template
db = MagicMock()
latest = MagicMock(id="plan-xyz")
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = latest
assert resolve_latest_plan_by_template(db, template_id=" tpl1 ", user_id="u1") == "plan-xyz"
def test_no_plan_returns_none(self):
from app.services.generation_common import resolve_latest_plan_by_template
db = MagicMock()
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
def test_db_exception_returns_none_and_logs(self, caplog):
from app.services.generation_common import resolve_latest_plan_by_template
db = MagicMock()
db.query.side_effect = RuntimeError("boom")
with caplog.at_level("WARNING"):
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
assert any("查找最新plan失败" in rec.message for rec in caplog.records)
+15
View File
@@ -46,6 +46,21 @@ class TestSplitScriptIntoSentences(unittest.TestCase):
result = _split_script_into_sentences("没有标点的句子")
self.assertEqual(result, ["没有标点的句子"])
def test_chinese_comma_not_split(self):
"""Issue #1892:中文逗号「,」不应分句,保持完整小句。"""
result = _split_script_into_sentences("卖花的叫花无缺,卖姜的叫姜子牙。")
self.assertEqual(result, ["卖花的叫花无缺,卖姜的叫姜子牙"])
def test_english_comma_not_split(self):
"""Issue #1892:英文逗号「,」不应分句。"""
result = _split_script_into_sentences("Hello, how are you?")
self.assertEqual(result, ["Hello, how are you"])
def test_comma_between_sentences_kept_in_sentence(self):
"""两个句号间带逗号的长句:逗号不切,只按句号切。"""
result = _split_script_into_sentences("第一句,带逗号。第二句,也带逗号!")
self.assertEqual(result, ["第一句,带逗号", "第二句,也带逗号"])
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
"""Tests for _estimate_sentence_timings_by_chars."""
+75 -1
View File
@@ -50,7 +50,7 @@ def _make_auth_user(user_id: str = "user_001") -> MagicMock:
class TestVoiceClonePreview:
"""克隆音色试听接口测试。"""
def _call_preview(self, profile, cosyvoice_mock, text="", user_id="user_001"):
def _call_preview(self, profile, cosyvoice_mock, text="", user_id="user_001", speed=1.0, emotion=""):
"""调用路由函数,模拟 FastAPI 注入依赖。"""
from app.api.routes.voice_clones import get_voice_clone_preview
@@ -93,6 +93,8 @@ class TestVoiceClonePreview:
result = get_voice_clone_preview(
clone_id=profile.id if profile else "nonexistent",
text=text,
speed=speed,
emotion=emotion,
authenticated_user=_make_auth_user(user_id),
repository=repo,
cosyvoice=cosyvoice_mock,
@@ -129,6 +131,8 @@ class TestVoiceClonePreview:
call_kwargs = cosyvoice.synthesize_speech.call_args
assert call_kwargs.kwargs["voice_id"] == "clone_voice_001"
assert call_kwargs.kwargs["format"] == "mp3"
assert call_kwargs.kwargs["speed"] == 1.0
assert call_kwargs.kwargs.get("emotion", "") == ""
def test_preview_custom_text(self) -> None:
"""自定义试听文本。"""
@@ -161,6 +165,76 @@ class TestVoiceClonePreview:
assert exc_info.value.status_code == 404
cosyvoice.synthesize_speech.assert_not_called()
def test_preview_custom_speed_emotion_passed_through(self) -> None:
"""Issue #1897: speed/emotion 参数透传到 cosyvoice.synthesize_speech。"""
from app.api.routes.voice_clones import _clone_preview_cache
_clone_preview_cache.clear()
profile = _make_profile()
cosyvoice = MagicMock()
cosyvoice.synthesize_speech.return_value = SynthesizeResult(
audio_url="https://oss.example.com/preview/fast.mp3",
duration=2.0,
file_size=32000,
)
result = self._call_preview(profile, cosyvoice, speed=1.3, emotion="excited")
assert result.audio_url == "https://oss.example.com/preview/fast.mp3"
call_kwargs = cosyvoice.synthesize_speech.call_args.kwargs
assert call_kwargs["speed"] == 1.3
assert call_kwargs["emotion"] == "excited"
def test_preview_invalid_emotion_rejected(self) -> None:
"""Issue #1897: 非法 emotion 值返回 400。"""
from app.api.routes.voice_clones import _clone_preview_cache
_clone_preview_cache.clear()
profile = _make_profile()
cosyvoice = MagicMock()
with pytest.raises(HTTPException) as exc_info:
self._call_preview(profile, cosyvoice, emotion="angry")
assert exc_info.value.status_code == 400
cosyvoice.synthesize_speech.assert_not_called()
def test_preview_non_default_speed_no_cache(self) -> None:
"""Issue #1897: 自定义 speed/emotion 不走缓存。"""
from app.api.routes.voice_clones import _clone_preview_cache
_clone_preview_cache.clear()
profile = _make_profile()
cosyvoice = MagicMock()
cosyvoice.synthesize_speech.side_effect = [
SynthesizeResult(audio_url="https://example.com/fast.mp3", duration=2.0, file_size=32000),
SynthesizeResult(audio_url="https://example.com/slow.mp3", duration=4.0, file_size=60000),
]
# 非默认speed — 不应缓存
result1 = self._call_preview(profile, cosyvoice, speed=1.5)
result2 = self._call_preview(profile, cosyvoice, speed=0.7, emotion="calm")
assert cosyvoice.synthesize_speech.call_count == 2
assert result1.audio_url != result2.audio_url
def test_preview_valueerror_from_cosyvoice_returns_400(self) -> None:
"""Issue #1897: cosyvoice 因 speed 非法等抛 ValueError 时返回 400(与 TTS preview 一致)。"""
from app.api.routes.voice_clones import _clone_preview_cache
_clone_preview_cache.clear()
profile = _make_profile()
cosyvoice = MagicMock()
cosyvoice.synthesize_speech.side_effect = ValueError("speed out of range")
with pytest.raises(HTTPException) as exc_info:
self._call_preview(profile, cosyvoice)
assert exc_info.value.status_code == 400
def test_preview_not_ready_pending(self) -> None:
"""pending 状态的克隆音色不能试听。"""
from app.api.routes.voice_clones import _clone_preview_cache
@@ -57,7 +57,7 @@ class TestWritebackEditPlanConfig:
mock_db.query.assert_called_once()
mock_db.commit.assert_not_called()
# ---- 行 170-182: 正常写入 + title_config ----
# ---- 行 170-182: 正常写入 + title(#1901 统一字段名) ----
def test_success_with_title_config(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
@@ -69,7 +69,11 @@ class TestWritebackEditPlanConfig:
)
assert mock_plan.config["generation_task_id"] == "task_456"
assert mock_plan.config["title_config"] == {"text": "标题", "font_size": 36}
# #1901: 统一写到 "title" 字段,且 font_size 已归一化为 size
assert mock_plan.config["title"]["text"] == "标题"
assert mock_plan.config["title"]["size"] == 36
assert mock_plan.config["title"]["font_size"] == 36
assert "title_config" not in mock_plan.config
assert mock_plan.config["existing_key"] == "existing_value"
mock_db.commit.assert_called_once()
@@ -80,7 +84,7 @@ class TestWritebackEditPlanConfig:
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_789", title_config=None, db=mock_db)
assert mock_plan.config["generation_task_id"] == "task_789"
assert "title_config" not in mock_plan.config
assert "title" not in mock_plan.config or not mock_plan.config.get("title")
mock_db.commit.assert_called_once()
# ---- 行 170: config 不是 dict → 兜底空 dict ----
@@ -115,12 +119,12 @@ class TestWritebackEditPlanConfig:
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
mock_db.rollback.assert_called_once()
# ---- 行 173: title_config 为空 dict → 不写入 title_config ----
# ---- 行 173: title_config 为空 dict → 不写入 title ----
def test_empty_title_config_not_written(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config={}, db=mock_db)
# 空 dict 为 falsy,不写入
assert "title_config" not in mock_plan.config
assert "title" not in mock_plan.config or not mock_plan.config.get("title")
assert mock_plan.config["generation_task_id"] == "task_1"