Compare commits

..

32 Commits

Author SHA1 Message Date
xiaoxia e7bd262c86 chore: retry CI
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 47s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 54s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 31s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m24s
AI Code Review / AI Code Review (pull_request) Failing after 1m33s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1m23s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 12s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 22s
CI/CD Pipeline / CI Gate (pull_request) Failing after 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 959h25m33s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 959h25m34s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 959h25m34s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 959h25m34s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 959h28m58s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 959h25m35s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 959h28m59s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 959h25m36s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 959h29m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 959h29m38s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 959h29m42s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 959h29m42s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 959h29m42s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 959h58m54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 960h2m15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 960h2m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 960h2m59s
2026-08-07 15:57:31 +08:00
xiaoxia 036f2a763f chore: trigger CI rerun
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 39s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m5s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 33s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m44s
AI Code Review / AI Code Review (pull_request) Failing after 1m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1m27s
CI/CD Pipeline / Validate - Code Quality (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
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 959h32m13s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 959h32m15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 959h32m51s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 959h32m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 959h32m53s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 959h32m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 959h32m55s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 960h5m34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 960h6m10s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 960h6m15s
2026-08-07 15:54:17 +08:00
xiaoxia 4ebc75f265 fix: 修复上传音频弹窗 TS 类型错误和 prettier 格式问题
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 59s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m6s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 32s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1m30s
AI Code Review / AI Code Review (pull_request) Successful in 2m24s
CI/CD Pipeline / Validate - Code Quality (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
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 959h35m35s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 959h35m37s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 959h36m13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 959h36m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 959h36m17s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 959h36m19s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 959h36m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 959h36m19s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 960h8m57s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 960h9m35s
- FormFields.tsx: genderIcons 添加 elderly 字段,修复 TS2741 错误
- FileInfoCard.tsx / UploadProgress.tsx: 修复 prettier 格式问题
2026-08-07 15:50:43 +08:00
xiaoxia fddddd08e6 feat: 优化上传音频弹窗视觉 (UploadVoiceModal)
- FileUploadZone: 上传区增加 hover 动效、圆形图标底托、柔和渐变背景
- FileInfoCard: 文件图标增加圆形背景色块、微阴影和边框
- FormFields: 标签加粗加深、input/textarea 增加 focus 动效、性别按钮增加图标前缀
- ActionButtons: 替换为 Ant Design Button 组件,保持风格统一
- UploadProgress: 增加卡片式背景、圆角边框
- UploadVoiceModal: 标题加粗、间距优化

所有修改仅涉及视觉样式,不改变业务逻辑和接口调用
2026-08-07 15:50:43 +08:00
xiaoxia f9e7aa9887 fix: 隐藏前3步生成结果面板 + 删除剪辑方案信息卡片 + 修复遗留lint错误 (#1259)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-08-07 15:50:32 +08:00
xiaoxia 5034e25749 fix: 配额系统添加 pro 套餐并支持未知套餐 fallback 到 free (#1262)
CI/CD Pipeline / Canary Release to Production (push) Failing after 959h55m47s
CI/CD Pipeline / CI Gate (push) Failing after 960h0m39s
CI/CD Pipeline / Production Browser E2E (push) Failing after 960h0m38s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 960h0m39s
CI/CD Pipeline / Build Production Web Image (push) Failing after 960h0m39s
CI/CD Pipeline / Build Production API Image (push) Failing after 960h0m39s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 960h10m25s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 960h10m29s
CI/CD Pipeline / PR Build Web Image (push) Failing after 960h10m26s
CI/CD Pipeline / PR Build API Image (push) Failing after 960h10m27s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m27s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m14s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 50s
CI/CD Pipeline / Unit Tests (push) Failing after 9m47s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m47s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m53s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m34s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 35m24s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Deploy Production (push) Failing after 960h33m58s
CI/CD Pipeline / Frontend Lint (push) Failing after 960h43m48s
2026-08-07 15:16:44 +08:00
xiaoxia df8325542d fix: 上传音频弹窗修复 — 去掉性别选择 + 优化错误提示 + 更新文案 (#1258)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 43s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m36s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m19s
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / Unit Tests (push) Successful in 9m24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 18m56s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 19m53s
CI/CD Pipeline / Canary Release to Production (push) Failing after 960h43m1s
CI/CD Pipeline / Production Browser E2E (push) Failing after 961h6m8s
CI/CD Pipeline / CI Gate (push) Failing after 961h6m9s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 961h6m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 961h6m9s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 961h15m33s
CI/CD Pipeline / PR Build Web Image (push) Failing after 961h15m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 961h15m34s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 961h15m35s
CI/CD Pipeline / Deploy Production (push) Failing after 961h39m27s
CI/CD Pipeline / Build Production API Image (push) Failing after 961h39m28s
CI/CD Pipeline / PR Build API Image (push) Failing after 961h48m52s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-07 13:36:00 +08:00
xiaoxia 6c70056a12 feat: 视频合成完成后生成封面 (#1255)
CI/CD Pipeline / Canary Release to Production (push) Failing after 961h59m30s
CI/CD Pipeline / Production Browser E2E (push) Failing after 962h22m31s
CI/CD Pipeline / CI Gate (push) Failing after 962h22m33s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 962h22m33s
CI/CD Pipeline / Build Production Web Image (push) Failing after 962h22m33s
CI/CD Pipeline / Build Production API Image (push) Failing after 962h22m33s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 962h31m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 962h31m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 962h32m0s
CI/CD Pipeline / PR Build Web Image (push) Failing after 962h31m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 44m35s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 39s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m10s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m14s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 55s
CI/CD Pipeline / Unit Tests (push) Successful in 9m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m52s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m0s
CI/CD Pipeline / Deploy Production (push) Failing after 962h55m50s
CI/CD Pipeline / PR Build API Image (push) Failing after 963h5m18s
2026-08-07 12:50:54 +08:00
xiaoxia e7de349db6 fix: 封面预览比例标签从 16:9 改为 9:16 (#1254)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-08-07 12:43:37 +08:00
xiaoxia 4931554cb8 feat: Step4 标题库选择与手动输入合并为单一输入框 (#1253)
CI/CD Pipeline / Canary Release to Production (push) Failing after 962h19m33s
CI/CD Pipeline / Production Browser E2E (push) Failing after 962h34m52s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 962h34m54s
CI/CD Pipeline / Build Production Web Image (push) Failing after 962h34m54s
CI/CD Pipeline / Deploy Production (push) Failing after 962h34m53s
CI/CD Pipeline / Build Production API Image (push) Failing after 962h34m54s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 962h44m36s
CI/CD Pipeline / PR Build Web Image (push) Failing after 962h44m38s
CI/CD Pipeline / PR Build API Image (push) Failing after 962h44m38s
CI/CD Pipeline / Frontend Lint (push) Failing after 962h44m38s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 962h45m21s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 56s
CI/CD Pipeline / Unit Tests (push) Successful in 9m43s
CI/CD Pipeline / Integration Tests (push) Successful in 2m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m48s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m45s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 19m42s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / CI Gate (push) Failing after 963h8m13s
2026-08-07 12:35:30 +08:00
xiaoxia 9e57d35b2e feat: Step5 配音选择改为展示用户上传的配音素材 (#1252)
CI/CD Pipeline / Canary Release to Production (push) Failing after 962h56m13s
CI/CD Pipeline / Production Browser E2E (push) Failing after 963h0m46s
CI/CD Pipeline / Deploy Production (push) Failing after 963h0m47s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 963h0m48s
CI/CD Pipeline / Build Production Web Image (push) Failing after 963h0m48s
CI/CD Pipeline / Build Production API Image (push) Failing after 963h0m48s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 963h10m22s
CI/CD Pipeline / PR Build API Image (push) Failing after 963h10m22s
CI/CD Pipeline / Frontend Lint (push) Failing after 963h10m23s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 963h10m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 11m9s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m24s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m56s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m29s
CI/CD Pipeline / Unit Tests (push) Successful in 9m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / CI Gate (push) Failing after 963h34m6s
CI/CD Pipeline / PR Build Web Image (push) Failing after 963h43m40s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-07 12:06:00 +08:00
xiaoxia ce3cd080f4 Merge pull request 'fix: Canvas标题居中 - CSS尺寸匹配视频渲染区域,消除容器/视频宽度不一致导致的偏移' (#1251) from fix/canvas-titlecentering-v2 into develop
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m24s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m7s
CI/CD Pipeline / Unit Tests (push) Successful in 10m15s
CI/CD Pipeline / Integration Tests (push) Successful in 2m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17m19s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 19m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Canary Release to Production (push) Failing after 963h10m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 963h33m34s
CI/CD Pipeline / Deploy Production (push) Failing after 963h33m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 963h33m37s
CI/CD Pipeline / Build Production Web Image (push) Failing after 963h33m37s
CI/CD Pipeline / Build Production API Image (push) Failing after 963h33m37s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 963h43m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 963h43m51s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 963h43m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 963h43m53s
CI/CD Pipeline / CI Gate (push) Failing after 964h6m56s
CI/CD Pipeline / PR Build Web Image (push) Failing after 964h17m8s
2026-08-07 11:43:19 +08:00
SaaS Frontend Agent 344a87c800 fix: Canvas标题居中 - CSS尺寸匹配视频渲染区域,消除容器/视频宽度不一致导致的偏移
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 43s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 48s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m38s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m50s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m12s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m31s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m47s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 40s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 48s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 963h48m17s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 963h48m18s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 963h48m19s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 963h48m18s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 963h48m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 963h51m16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 963h51m19s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 963h51m18s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 963h51m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 963h51m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 963h52m4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 963h52m8s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 963h52m8s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 963h52m8s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 964h21m38s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 964h21m38s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 964h25m0s
根因:Canvas CSS尺寸为100%×100%(占满容器),但物理像素按视频实际渲染尺寸设置。
当容器和视频宽度不一致时浏览器拉伸Canvas,导致x=offsetX+w/2与实际显示位置不匹配。

修复方案:
1. drawVideoTitle: 通过getBoundingClientRect获取视频实际渲染区域,
   用canvas.style直接设置CSS位置和尺寸匹配视频(而非容器)
2. drawTitleOnCanvas: 移除offsetX/offsetY参数,坐标系基于Canvas自身,
   x=w/2即居中
3. 选择直接DOM操作而非state,避免re-render覆盖style的时序问题
2026-08-07 11:34:43 +08:00
xiaoxia 91ac40dbef fix(render_audio): drop source video audio in mix_audio (#1249)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m18s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m30s
CI/CD Pipeline / Unit Tests (push) Successful in 9m23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m14s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Canary Release to Production (push) Failing after 980h11m34s
CI/CD Pipeline / Deploy Production (push) Failing after 980h19m8s
CI/CD Pipeline / CI Gate (push) Failing after 980h19m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 980h19m9s
CI/CD Pipeline / Build Production API Image (push) Failing after 980h19m9s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 980h28m30s
CI/CD Pipeline / PR Build Web Image (push) Failing after 980h28m31s
CI/CD Pipeline / PR Build API Image (push) Failing after 980h28m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 980h28m33s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 980h28m34s
CI/CD Pipeline / Production Browser E2E (push) Failing after 980h52m27s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 980h52m28s
2026-08-06 18:58:41 +08:00
xiaoxia a66ecc26d8 Merge pull request 'fix: 删除no-video标题预览 + 视频素材可播放预览 + Canvas标题居中修复' (#1248) from feat/no-video-cleanup-and-centering-fix into develop
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 48s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 32s
CI/CD Pipeline / Integration Tests (push) Successful in 3m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
CI/CD Pipeline / Unit Tests (push) Successful in 11m27s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m25s
CI/CD Pipeline / Production Browser E2E (push) Failing after 981h31m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 981h31m38s
CI/CD Pipeline / Canary Release to Production (push) Failing after 981h31m37s
CI/CD Pipeline / Build Production Web Image (push) Failing after 981h31m39s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 981h43m4s
CI/CD Pipeline / Deploy Production (push) Failing after 981h31m37s
CI/CD Pipeline / PR Build Web Image (push) Failing after 981h43m4s
CI/CD Pipeline / Frontend Lint (push) Failing after 981h43m6s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 981h43m8s
CI/CD Pipeline / CI Gate (push) Failing after 982h4m56s
CI/CD Pipeline / Build Production API Image (push) Failing after 982h4m57s
CI/CD Pipeline / PR Build API Image (push) Failing after 982h16m24s
2026-08-06 17:44:08 +08:00
xiaoxia ffe32330a1 fix: 修复视频hover播放的DOM引用安全性检查
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m26s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m35s
AI Code Review / AI Code Review (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m1s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 38s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 53s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 981h46m22s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 981h46m22s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 981h46m23s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 981h46m23s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 981h46m24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 981h50m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 981h50m43s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 981h51m22s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 981h51m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 981h51m24s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 981h51m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 981h51m26s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 982h19m40s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 982h19m41s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 982h24m0s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 982h24m40s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 982h24m44s
增加document.body.contains检查,确保操作仍在DOM中的视频元素
2026-08-06 17:35:50 +08:00
xiaoxia 0d8acb38b7 docs: 添加设计说明注释,明确no-video标题预览为有意删除
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 981h51m34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 981h51m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 981h51m36s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 981h51m38s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 981h51m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 982h24m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 982h24m56s
标题预览仅在有视频时显示,无视频状态下不再单独预览标题。
2026-08-06 17:35:38 +08:00
xiaoxia 0ba007f326 style: prettier格式化修复
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m19s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m13s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
AI Code Review / AI Code Review (pull_request) Failing after 6m21s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 981h54m43s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 981h54m44s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 981h54m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 981h54m45s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 981h54m45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 981h59m11s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 981h59m13s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 981h59m55s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 981h59m55s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 981h59m59s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 981h59m55s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 981h59m59s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 981h59m59s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 982h28m2s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 982h28m3s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 982h32m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 982h33m14s
2026-08-06 17:27:17 +08:00
xiaoxia 0d3c3e81ac fix: 修复视频多实例播放问题+ref回调改为poster属性
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 39s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m33s
AI Code Review / AI Code Review (pull_request) Failing after 2m15s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m7s
CI/CD Pipeline / CI Gate (pull_request) Failing after 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 982h0m32s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 982h0m32s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 982h0m33s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 982h0m33s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 982h0m33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 982h4m59s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 982h5m1s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 982h5m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 982h5m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 982h5m37s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 982h5m40s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 982h5m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 982h5m41s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 982h33m50s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 982h33m51s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 982h38m18s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 982h38m56s
- 新增 activeVideoRef 追踪当前播放视频,播放新视频前暂停其他视频
- 移除内联 ref 回调,改用 poster 属性直接设置封面
- mouseLeave 时同步清理 activeVideoRef
2026-08-06 17:21:36 +08:00
xiaoxia 57c181ce97 fix: 视频素材缩略图改为hover播放,移除autoPlay避免性能问题
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 29s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m1s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m24s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m30s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 2m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Failing after 2m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m20s
CI/CD Pipeline / Validate - Code Quality (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
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 982h9m6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 982h9m8s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 982h9m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 982h9m36s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 982h9m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 982h9m37s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 982h9m37s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 982h9m38s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 982h42m25s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 982h42m53s
2026-08-06 17:17:39 +08:00
xiaoxia c411b732bd feat: 视频素材缩略图改为可播放video预览
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m12s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m36s
AI Code Review / AI Code Review (pull_request) Failing after 2m16s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m53s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m26s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m37s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 982h10m24s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 982h10m24s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 982h10m25s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 982h10m25s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 982h10m26s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 982h14m42s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 982h14m42s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 982h15m4s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 982h15m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 982h15m6s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 982h15m8s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 982h15m8s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 982h43m42s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 982h43m43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 982h47m58s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 982h48m22s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 982h48m26s
- 视频素材(isVideo+file_url)使用video标签自动循环播放
- 无file_url时fallback到thumbnail_url静态图
- 非视频素材保持原有img/fallback逻辑
2026-08-06 17:11:55 +08:00
xiaoxia 8507729ca9 refactor: 删除no-video标题预览 + Canvas居中改用getBoundingClientRect
- 删除 noVideoCanvasRef/noVideoContainerRef 声明
- 删除 drawNoVideoTitle 回调
- 删除 no-video ResizeObserver effect
- 字体加载 effect 移除 drawNoVideoTitle 引用
- 删除 no-video JSX 块
- drawVideoTitle 改用 videoEl.getBoundingClientRect() 获取实际渲染尺寸
- 用 containerRect 计算 offsetX/offsetY 精确补偿视频偏移
2026-08-06 17:11:45 +08:00
xiaoxia 39a9d0b7f2 feat: 素材列表缩略图预览 + Canvas标题居中修复 (#1247)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m32s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m19s
CI/CD Pipeline / Integration Tests (push) Successful in 7m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m48s
CI/CD Pipeline / Unit Tests (push) Successful in 18m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 47s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 987h33m7s
CI/CD Pipeline / Production Browser E2E (push) Failing after 987h37m40s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 987h56m14s
CI/CD Pipeline / Deploy Production (push) Failing after 987h37m41s
CI/CD Pipeline / PR Build Web Image (push) Failing after 987h56m14s
CI/CD Pipeline / CI Gate (push) Failing after 987h37m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 987h37m42s
CI/CD Pipeline / Build Production Web Image (push) Failing after 987h37m42s
CI/CD Pipeline / PR Build API Image (push) Failing after 987h56m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 987h56m16s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 987h56m18s
CI/CD Pipeline / Build Production API Image (push) Failing after 988h11m0s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-06 11:31:00 +08:00
xiaoxia bc7d00b470 Merge pull request 'feat: 用 Canvas 替换 CSS overlay 实现标题预览' (#1246) from feat/title-preview-canvas into develop
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m26s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m53s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m42s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m2s
CI/CD Pipeline / Unit Tests (push) Successful in 9m4s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1004h12m37s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1004h13m52s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1004h13m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1004h13m50s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1004h22m14s
CI/CD Pipeline / Deploy Production (push) Failing after 1004h13m51s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1004h22m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 1004h22m57s
CI/CD Pipeline / PR Build API Image (push) Failing after 1004h22m18s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1004h22m59s
CI/CD Pipeline / CI Gate (push) Failing after 1004h47m9s
CI/CD Pipeline / Build Production API Image (push) Failing after 1004h47m10s
2026-08-05 19:04:18 +08:00
xiaoxia 43d4cd041c style: prettier 格式化 Canvas 标题预览文件
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m37s
AI Code Review / AI Code Review (pull_request) Successful in 2m28s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m40s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 42s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m7s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1004h25m38s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1004h25m39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1004h25m39s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1004h25m39s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1004h25m40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 1004h28m33s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1004h29m14s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 1004h28m35s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1004h29m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1004h29m18s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1004h29m20s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1004h29m20s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1004h29m21s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1004h58m57s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1004h58m58s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1005h1m54s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1005h2m34s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-05 18:57:56 +08:00
xiaoxia 5f427386e3 feat: 用 Canvas 替换 CSS overlay 实现标题预览
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 50s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 50s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 44s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m38s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m54s
CI/CD Pipeline / Validate - Code Quality (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 1004h32m15s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1004h32m37s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1004h32m57s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1004h32m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1004h33m1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1004h33m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1004h33m5s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 1005h5m53s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1005h6m17s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1005h6m23s
根因:CSS 文字布局与 ASS 字幕渲染引擎不同,不同字数/字号/字体下行为不可预测。
方案:用 canvas 绘制标题,放弃 CSS overlay。

- 新增 wrapText() 逐字换行函数
- 新增 drawTitleOnCanvas() 统一绘制函数(描边/阴影/多行居中/高清屏)
- video 模式 canvas 叠加在视频容器上
- no-video 模式 canvas 叠加在暗色预览区(字号缩放0.75)
- ResizeObserver 监听容器变化,窗口缩放自动重绘
- 删除 positionOverlayStyle 和 buildTitleStyle CSS 函数
- error 状态 canvas 不渲染

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-05 18:54:01 +08:00
xiaoxia e65edd6f53 fix: 标题预览水平居中,叠加层加flex居中 (#1245)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m3s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m11s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m53s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m53s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m8s
CI/CD Pipeline / Unit Tests (push) Successful in 10m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m12s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1006h25m8s
CI/CD Pipeline / Deploy Production (push) Failing after 1006h25m21s
CI/CD Pipeline / CI Gate (push) Failing after 1006h25m21s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1006h25m21s
CI/CD Pipeline / Build Production API Image (push) Failing after 1006h25m21s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1006h35m35s
CI/CD Pipeline / PR Build API Image (push) Failing after 1006h35m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1006h36m17s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1006h58m38s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1006h58m39s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1007h8m51s
CI/CD Pipeline / Frontend Lint (push) Failing after 1007h9m33s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-05 16:51:00 +08:00
xiaoxia c3858b0cf4 Merge pull request 'fix: 标题预览span改div,确保长标题自动换行不超出视频边界' (#1244) from fix/title-preview-wrap into develop
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m50s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m9s
CI/CD Pipeline / Integration Tests (push) Successful in 2m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Unit Tests (push) Successful in 9m28s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1008h27m57s
CI/CD Pipeline / Deploy Production (push) Failing after 1008h27m58s
CI/CD Pipeline / CI Gate (push) Failing after 1008h27m59s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1008h27m59s
CI/CD Pipeline / Build Production API Image (push) Failing after 1008h27m59s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1008h36m52s
CI/CD Pipeline / PR Build API Image (push) Failing after 1008h36m54s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1008h37m31s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1009h1m16s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1009h1m17s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1009h10m9s
CI/CD Pipeline / Frontend Lint (push) Failing after 1009h10m45s
2026-08-05 14:49:46 +08:00
xiaoxia 9ba14da6c3 style: prettier format
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m31s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m53s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m42s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m31s
AI Code Review / AI Code Review (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m40s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 40s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 41s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1008h38m29s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1008h38m32s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1008h38m31s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1008h38m32s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1008h38m31s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1008h38m31s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1008h43m8s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1008h43m8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 1008h42m28s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 1008h42m33s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1008h43m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1008h42m35s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1008h43m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1008h43m12s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1009h11m48s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1009h16m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1009h16m27s
2026-08-05 14:44:04 +08:00
xiaoxia 2e4f61e11d fix: 标题预览增强换行+错误状态隐藏标题预览
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 45s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1008h43m15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1008h43m47s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1008h43m49s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1008h44m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1008h43m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1008h43m53s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1008h44m1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1009h17m18s
- buildTitleStyle: 新增 display:block / width:100% / boxSizing:border-box / overflowWrap:break-word
- 错误状态下不显示标题预览区域,避免遮挡「重新生成」按钮
- 更新注释说明
2026-08-05 14:43:16 +08:00
xiaoxia 643239279e fix: 标题预览span改div,确保长标题自动换行不超出视频边界
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API 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 / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1008h44m13s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1008h44m15s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1008h44m17s
- 视频叠加区域 span → div(block 元素,wordBreak 可靠生效)
- 无视频预览区域 span → div(同上)
- 父容器已有 width:100% 约束,div 作为 block 元素自动受限于父宽度
2026-08-05 14:39:40 +08:00
xiaoxia bb5bed0f4c refactor: 统一智能选素材逻辑,迁移 generation_tasks smart 模式到 smart_match (#1243)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m22s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m20s
CI/CD Pipeline / Integration Tests (push) Successful in 2m42s
CI/CD Pipeline / Unit Tests (push) Successful in 9m25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1011h41m47s
CI/CD Pipeline / Deploy Production (push) Failing after 1011h49m52s
CI/CD Pipeline / CI Gate (push) Failing after 1011h49m52s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1011h59m16s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1011h49m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1011h49m53s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1011h59m18s
CI/CD Pipeline / Frontend Lint (push) Failing after 1011h59m20s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1011h59m21s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1012h23m9s
CI/CD Pipeline / Build Production API Image (push) Failing after 1012h23m11s
CI/CD Pipeline / PR Build API Image (push) Failing after 1012h32m35s
2026-08-05 11:27:57 +08:00
32 changed files with 1350 additions and 657 deletions
+2 -100
View File
@@ -1845,27 +1845,6 @@
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -1935,14 +1914,6 @@
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2009,14 +1980,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -3110,14 +3081,6 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4028,18 +3991,6 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4466,17 +4417,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5008,44 +4948,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -60,7 +60,6 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
return () => {
active = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
/* ── 组件卸载时清理音频资源 ── */
+17 -15
View File
@@ -224,21 +224,23 @@ const GeneratePage: React.FC = () => {
/>
)}
{/* 正式生成结果 */}
<GenerateResultPanel
generated={generated}
generating={generating}
progress={progress}
generateError={generateError}
generatedVideos={generatedVideos}
onVideoPreview={(video) => {
setPreviewVideo(video)
setPreviewModalOpen(true)
}}
onDownload={handleDownload}
onShare={handleShare}
onGoToLibrary={() => navigate("/app/products")}
/>
{/* 正式生成结果(Step4+ 才显示) */}
{currentStep >= 4 && (
<GenerateResultPanel
generated={generated}
generating={generating}
progress={progress}
generateError={generateError}
generatedVideos={generatedVideos}
onVideoPreview={(video) => {
setPreviewVideo(video)
setPreviewModalOpen(true)
}}
onDownload={handleDownload}
onShare={handleShare}
onGoToLibrary={() => navigate("/app/products")}
/>
)}
</div>
</div>
@@ -94,14 +94,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVoice,
onSelectedVoiceChange,
voiceMode,
onVoiceModeChange,
selectedClonedVoice,
onSelectedClonedVoiceChange,
clonedVoices,
addClone,
hasProcessing,
cloneModalOpen,
onCloneModalOpenChange,
generateCount,
onGenerateCountChange,
generating,
@@ -170,16 +164,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
<Step5VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
voiceMode={voiceMode}
onVoiceModeChange={onVoiceModeChange}
selectedClonedVoice={selectedClonedVoice}
onSelectedClonedVoiceChange={onSelectedClonedVoiceChange}
clonedVoices={clonedVoices}
addClone={addClone}
hasProcessing={hasProcessing}
cloneModalOpen={cloneModalOpen}
onCloneModalOpenChange={onCloneModalOpenChange}
titleText={titleSettings.title}
/>
)
case 6:
@@ -1,9 +1,17 @@
/**
* 右侧预览视频面板
* Step3 生成预览后常驻显示预览视频
* Step4+ 显示标题实时预览
* Step4+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
*
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
*
* Canvas 居中修复说明:
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect),
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移。
*/
import React from "react"
import React, { useRef, useEffect, useCallback } from "react"
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
import type { TitleSettings } from "../types"
@@ -21,40 +29,131 @@ interface PreviewVideoPanelProps {
titleSettings?: TitleSettings
}
/** 根据 position 返回 absolute 定位样式(叠加在视频上) */
const positionOverlayStyle = (position: string): React.CSSProperties => {
/* ── Canvas 绘制工具函数 ── */
/**
* 将文本按 maxWidth 逐字换行,返回行数组。
* 与 ASS 字幕引擎的逐字换行行为一致。
*/
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = []
let currentLine = ""
for (const char of text) {
const testLine = currentLine + char
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
lines.push(currentLine)
currentLine = char
} else {
currentLine = testLine
}
}
if (currentLine) lines.push(currentLine)
return lines
}
/**
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
*
* Canvas 已通过 CSS 定位到视频实际渲染位置,
* 坐标系基于 Canvas 自身尺寸,居中直接使用 w/2。
*
* @param ctx canvas 上下文
* @param w canvas CSS 宽度(= 视频渲染宽度)
* @param h canvas CSS 高度(= 视频渲染高度)
* @param text 标题文字
* @param settings 标题样式
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
* @param position "top" | "center" | "bottom"
* @param topOffset 顶部/底部偏移量
*/
function drawTitleOnCanvas(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
text: string,
settings: TitleSettings,
paddingX: number,
position: string,
topOffset: number,
) {
const dpr = window.devicePixelRatio || 1
// 设置 canvas 物理像素尺寸(高清屏适配)
ctx.canvas.width = Math.round(w * dpr)
ctx.canvas.height = Math.round(h * dpr)
ctx.scale(dpr, dpr)
// 清除
ctx.clearRect(0, 0, w, h)
// 可用宽度 = 总宽 - 左右边距
const availableWidth = w - paddingX * 2
if (availableWidth <= 0) return
// 字体设置
const fontSize = Math.round(Math.min(settings.size, 36))
const fontWeight = settings.bold ? "bold" : "normal"
const fontStyle = settings.italic ? "italic" : "normal"
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
// 文字属性
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
const lineHeight = fontSize * 1.4
// 描边 & 阴影
if (settings.stroke) {
ctx.strokeStyle = "rgba(0,0,0,0.6)"
ctx.lineWidth = 2
ctx.lineJoin = "round"
}
if (settings.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.7)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
}
// 换行
const displayText = text && text.trim() ? text : "请选择或输入标题"
const lines = wrapText(ctx, displayText, availableWidth)
// 起始 Y:根据 position 计算
const totalTextHeight = lines.length * lineHeight
let startY: number
switch (position) {
case "top":
return { top: 16, left: 0, right: 0 }
startY = topOffset
break
case "center":
return { top: "50%", left: 0, right: 0, transform: "translateY(-50%)" }
startY = (h - totalTextHeight) / 2 + lineHeight / 2
break
case "bottom":
default:
return { bottom: 56, left: 0, right: 0 } // 56px = 给视频控制条留空间
startY = h - topOffset - totalTextHeight + lineHeight / 2
break
}
// 居中 x = w/2(Canvas 已定位到视频位置,无需额外偏移)
const x = w / 2
lines.forEach((line, i) => {
const y = startY + i * lineHeight
if (settings.stroke) ctx.strokeText(line, x, y)
ctx.fillText(line, x, y)
})
// 重置 shadow(避免影响后续绘制)
if (settings.shadow) {
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}
/** 根据 TitleSettings 生成文字样式 */
const buildTitleStyle = (settings: TitleSettings): React.CSSProperties => {
const style: React.CSSProperties = {
fontFamily: settings.font,
fontSize: Math.min(settings.size, 36), // 预览区域限制最大字号
color: settings.color,
fontWeight: settings.bold ? 700 : 400,
fontStyle: settings.italic ? "italic" : "normal",
textAlign: "center",
lineHeight: 1.4,
wordBreak: "break-word",
padding: "0 12px",
}
if (settings.stroke) {
style.WebkitTextStroke = "1px rgba(0,0,0,0.6)"
}
if (settings.shadow) {
style.textShadow = "2px 2px 4px rgba(0,0,0,0.7)"
}
return style
}
/* ── 组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
previewStatus,
@@ -71,6 +170,125 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
const isError = previewStatus === "error"
const showTitlePreview = !!titleSettings
// video 模式 refs
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
const fontLoadedRef = useRef(false)
/** 在 video canvas 上绘制标题 */
const drawVideoTitle = useCallback(() => {
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
if (!fontLoadedRef.current) return
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container || !titleSettings) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const containerRect = container.getBoundingClientRect()
if (containerRect.width <= 0 || containerRect.height <= 0) return
// 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
const videoEl = videoRef.current
let drawW = containerRect.width
let drawH = containerRect.height
let offsetX = 0
let offsetY = 0
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
const videoRect = videoEl.getBoundingClientRect()
drawW = videoRect.width
drawH = videoRect.height
offsetX = videoRect.left - containerRect.left
offsetY = videoRect.top - containerRect.top
}
// 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
canvas.style.left = `${offsetX}px`
canvas.style.top = `${offsetY}px`
canvas.style.width = `${drawW}px`
canvas.style.height = `${drawH}px`
// 绘制时,坐标系基于 Canvas 自身尺寸,无需额外偏移
drawTitleOnCanvas(
ctx,
drawW,
drawH,
titleText || "",
titleSettings,
40,
titleSettings.position,
60,
)
}, [titleText, titleSettings])
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
useEffect(() => {
if (!showTitlePreview || !hasPreview) return
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => {
drawVideoTitle()
})
observer.observe(container)
requestAnimationFrame(drawVideoTitle)
return () => observer.disconnect()
}, [showTitlePreview, hasPreview, drawVideoTitle])
// 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体
useEffect(() => {
if (!showTitlePreview || !titleSettings) {
fontLoadedRef.current = false
return
}
let cancelled = false
fontLoadedRef.current = false
const fontWeight = titleSettings.bold ? "bold" : ""
const fontStyle = titleSettings.italic ? "italic" : ""
const fontSpec =
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
const onFontReady = () => {
if (cancelled) return
fontLoadedRef.current = true
// ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态)
requestAnimationFrame(() => {
if (!cancelled) {
drawVideoTitle()
}
})
}
if (document.fonts.check(fontSpec)) {
onFontReady()
return
}
document.fonts
.load(fontSpec)
.then(() => onFontReady())
.catch(() => {
document.fonts.ready.then(() => onFontReady())
})
return () => {
cancelled = true
}
}, [showTitlePreview, titleSettings, drawVideoTitle])
// video 加载完成后重绘
const handleVideoLoaded = useCallback(() => {
if (showTitlePreview) {
requestAnimationFrame(drawVideoTitle)
}
}, [showTitlePreview, drawVideoTitle])
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
@@ -121,50 +339,35 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 预览成功 + 标题叠加 */}
{/* 预览成功 + Canvas 标题叠加 */}
{hasPreview && (
<div style={{ position: "relative" }}>
<div ref={containerRef} style={{ position: "relative" }}>
<div className="xx-preview-video">
<video src={previewResult.videoUrl} controls preload="metadata" />
<video
ref={videoRef}
src={previewResult.videoUrl}
controls
preload="metadata"
onLoadedMetadata={handleVideoLoaded}
/>
</div>
{/* 标题叠加在视频画面上 */}
{showTitlePreview && (
<div
<canvas
ref={canvasRef}
style={{
position: "absolute",
...positionOverlayStyle(titleSettings!.position),
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
pointerEvents: "none",
}}
>
<span style={buildTitleStyle(titleSettings!)}>
{titleText && titleText.trim() ? titleText : "请选择或输入标题"}
</span>
</div>
/>
)}
</div>
)}
{/* 无视频时的标题预览(idle/loading/error 状态下仍然显示标题预览) */}
{!hasPreview && showTitlePreview && (
<div
style={{
background: "rgba(0,0,0,0.3)",
borderRadius: 8,
minHeight: 64,
marginTop: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={buildTitleStyle(titleSettings!)}>
{titleText && titleText.trim() ? titleText : "请选择或输入标题"}
</span>
</div>
)}
{/* 预览信息 */}
{hasPreview && previewResult && (
<div className="xx-preview-info">
@@ -27,9 +27,9 @@ interface Step3GeneratePreviewProps {
}
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
templateName,
materialCount,
videoRatio,
templateName: _templateName,
materialCount: _materialCount,
videoRatio: _videoRatio,
previewStatus,
previewResult,
previewError,
@@ -119,48 +119,6 @@ const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
preload="metadata"
/>
</div>
{/* 剪辑方案信息 */}
<div className="xx-preview-plan-card">
<div className="xx-preview-plan-title">剪辑方案信息</div>
<div className="xx-preview-plan-info">
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">模板</span>
<span className="xx-preview-plan-value">{templateName}</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">素材数量</span>
<span className="xx-preview-plan-value">{materialCount}</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">片段数</span>
<span className="xx-preview-plan-value">{previewResult.clipCount} 段</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">转场次数</span>
<span className="xx-preview-plan-value">{previewResult.transitionCount} 次</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">素材使用率</span>
<span className="xx-preview-plan-value">{previewResult.materialUsage}%</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">实际时长</span>
<span className="xx-preview-plan-value">
{(typeof previewResult.duration === "number"
? previewResult.duration
: 0
).toFixed(1)}{" "}
秒
</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">视频比例</span>
<span className="xx-preview-plan-value">{videoRatio}</span>
</div>
</div>
<div className="xx-preview-plan-hint">💡 这是 480p 预览版,正式生成将输出高清视频</div>
</div>
</>
)}
</div>
@@ -2,7 +2,7 @@
* Step 4 标题设置组件
*/
import React from "react"
import { Select } from "antd"
import { AutoComplete } from "antd"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import type { TitleSettings } from "../types"
import { useStep4Title } from "../hooks/useStep4Title"
@@ -75,10 +75,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-divider">
<span>或手动选择</span>
</div>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
@@ -87,11 +83,11 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
</div>
<div className="xx-form-field">
<label>从标题库选择</label>
<Select
placeholder="请选择标题…"
<label>标题</label>
<AutoComplete
placeholder="输入或从标题库选择…"
allowClear
showSearch
maxLength={50}
style={{ width: "100%" }}
value={t.titleSettings.title || undefined}
onChange={(val) => t.updateTitle(val || "")}
@@ -99,9 +95,10 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
label: ut.content,
value: ut.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
}
filterOption={(inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}}
notFoundContent={
t.userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
@@ -111,15 +108,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
}
/>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label>或手动输入</label>
<input
placeholder="输入自定义标题…"
value={t.titleSettings.title}
onChange={(e) => t.updateTitle(e.target.value)}
maxLength={50}
/>
</div>
</>
)}
@@ -1,137 +1,250 @@
/**
* Step 5 配音选择组件
* 展示用户已上传的配音素材,支持选中、预览播放
*/
import React from "react"
import type { VoiceClone } from "@/api/voice-clone"
import { useStep5Voice } from "../hooks/useStep5Voice"
import VoiceRecommendSection from "./voice/VoiceRecommendSection"
import VoiceChoiceCard from "./voice/VoiceChoiceCard"
import PresetVoiceDetail from "./voice/PresetVoiceDetail"
import CustomVoicePanel from "./voice/CustomVoicePanel"
import SaveVoiceModal from "./voice/SaveVoiceModal"
import CloneVoiceSection from "./voice/CloneVoiceSection"
import React, { useState, useRef, useCallback } from "react"
import { useQuery } from "@tanstack/react-query"
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
interface Step5VoiceSelectProps {
selectedVoice: string
onSelectedVoiceChange: (voiceId: string) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (voiceId: string) => void
clonedVoices: VoiceClone[]
addClone: (voice: VoiceClone) => void
hasProcessing: boolean
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
titleText: string
onSelectedVoiceChange: (id: string) => void
}
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
const v = useStep5Voice(props)
/** 格式化时长 mm:ss */
const formatDuration = (seconds?: number): string => {
if (!seconds || seconds <= 0) return "00:00"
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
/** 格式化文件大小 */
const formatFileSize = (bytes?: number): string => {
if (!bytes || bytes <= 0) return "未知"
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
selectedVoice,
onSelectedVoiceChange,
}) => {
const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
// 获取用户上传的配音素材
const { data: materials = [], isLoading } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
/** 切换播放/暂停 */
const togglePlay = useCallback(
(material: AssetItem) => {
// 如果当前正在播放同一个素材,则暂停
if (playingId === material.id && audioRef.current) {
audioRef.current.pause()
setPlayingId(null)
return
}
// 停止之前的播放
if (audioRef.current) {
audioRef.current.pause()
}
// 创建新的 audio 元素播放
const audio = new Audio(material.file_url)
audioRef.current = audio
setPlayingId(material.id)
audio.onended = () => {
setPlayingId(null)
audioRef.current = null
}
audio.play().catch(() => {
setPlayingId(null)
audioRef.current = null
})
},
[playingId],
)
/** 选中素材 */
const handleSelect = useCallback(
(id: string) => {
onSelectedVoiceChange(id)
},
[onSelectedVoiceChange],
)
/** 跳转到配音库上传 */
const handleGoToUpload = useCallback(() => {
window.location.href = "/voices"
}, [])
// 加载中状态
if (isLoading) {
return (
<div className="xx-form-section">
<h3>🎙️ 选择配音</h3>
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
加载配音素材中...
</div>
</div>
)
}
// 空状态
if (materials.length === 0) {
return (
<div className="xx-form-section">
<h3>🎙️ 选择配音</h3>
<div
style={{
textAlign: "center",
padding: "60px 0",
color: "#999",
}}
>
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 16 }} />
<p style={{ fontSize: 16, marginBottom: 16 }}>暂无配音素材</p>
<button
type="button"
onClick={handleGoToUpload}
style={{
padding: "8px 24px",
background: "#1677ff",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: "pointer",
fontSize: 14,
}}
>
去配音库上传
</button>
</div>
</div>
)
}
return (
<div className="xx-form-section">
<h3>🎙️ 选择配音</h3>
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
从配音库中选择已上传的素材,点击卡片可预览播放
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 12,
}}
>
{materials.map((item) => {
const isSelected = selectedVoice === item.id
const isPlaying = playingId === item.id
<VoiceRecommendSection
presetVoices={v.presetVoices}
voiceRecommendLoading={v.voiceRecommendLoading}
voiceRecommendations={v.voiceRecommendations}
hasVoiceRecommend={v.hasVoiceRecommend}
onRecommend={v.handleVoiceRecommend}
onSelectVoice={v.handleSelectRecommendedVoice}
selectedVoiceId={v.selectedVoice}
voiceMode={v.voiceMode}
VOICE_GENDER_ICON={v.VOICE_GENDER_ICON}
/>
return (
<div
key={item.id}
onClick={() => handleSelect(item.id)}
style={{
padding: 16,
borderRadius: 8,
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
background: isSelected ? "#e6f4ff" : "#fff",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{/* 顶部:图标 + 播放按钮 */}
<div
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 8,
background: isSelected
? "linear-gradient(135deg, #1677ff, #4096ff)"
: "linear-gradient(135deg, #f0f0f0, #e8e8e8)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<AudioOutlined style={{ fontSize: 20, color: isSelected ? "#fff" : "#666" }} />
</div>
{item.file_url && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
togglePlay(item)
}}
style={{
width: 32,
height: 32,
borderRadius: "50%",
border: "none",
background: isPlaying ? "#ff4d4f" : "#1677ff",
color: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
}}
title={isPlaying ? "暂停" : "播放"}
>
<SoundOutlined />
</button>
)}
</div>
<div className="xx-divider">
<span>全部音色</span>
{/* 名称 */}
<div
style={{
fontSize: 14,
fontWeight: 500,
color: "#333",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={item.name}
>
{item.name}
</div>
{/* 时长 + 大小 */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
color: "#999",
}}
>
<span>{formatDuration(item.duration)}</span>
<span>{formatFileSize(item.file_size)}</span>
</div>
</div>
)
})}
</div>
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
{v.presetVoices.slice(0, 3).map((pv) => (
<VoiceChoiceCard
key={pv.voice_id}
selected={v.voiceMode === "preset" && v.selectedVoice === pv.voice_id}
onClick={() => v.handleSelectPresetVoice(pv.voice_id)}
avatar={v.VOICE_GENDER_ICON[pv.gender] ?? "✨"}
title={pv.name}
description={pv.description}
/>
))}
{v.presetVoicesLoading && (
<VoiceChoiceCard
selected={false}
onClick={() => {}}
avatar="⏳"
title="加载中…"
loading
/>
)}
<VoiceChoiceCard
selected={v.voiceMode === "clone"}
onClick={v.handleSelectCloneVoice}
avatar="🎤"
title="克隆我的声音"
description="上传语音样本克隆"
avatarStyle={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
/>
</div>
{v.voiceMode === "preset" && (
<PresetVoiceDetail
presetVoices={v.presetVoices}
selectedVoice={v.selectedVoice}
onSelect={v.handleSelectPresetVoice}
playingVoice={v.playingVoice}
onTogglePlay={v.toggleVoicePlay}
presetVoicesLoading={v.presetVoicesLoading}
/>
)}
{v.voiceMode === "custom" && (
<>
<CustomVoicePanel
customVoiceText={v.customVoiceText}
onTextChange={v.setCustomVoiceText}
synthesizePending={v.synthesizeMutation.isPending}
onSynthesize={v.handleSynthesizeVoice}
ttsError={v.ttsError}
customAudioUrl={v.customAudioUrl}
completedTtsJobId={v.completedTtsJobId}
onOpenSaveModal={v.handleOpenSaveModal}
/>
<SaveVoiceModal
open={v.saveModalOpen}
onClose={() => v.setSaveModalOpen(false)}
saveName={v.saveName}
onNameChange={v.setSaveName}
saveTagIds={v.saveTagIds}
onTagIdsChange={v.setSaveTagIds}
saveNewTag={v.saveNewTag}
onNewTagChange={v.setSaveNewTag}
onAddTag={v.handleAddTagInModal}
allTags={v.allTags}
savePending={v.saveToLibraryMutation.isPending}
onConfirm={v.handleConfirmSave}
/>
</>
)}
{v.voiceMode === "clone" && (
<CloneVoiceSection
clonedVoices={v.clonedVoices}
hasProcessing={v.hasProcessing}
selectedClonedVoice={v.selectedClonedVoice}
onSelect={v.handleSelectClonedVoice}
onOpenCloneModal={v.handleOpenCloneModal}
playingVoiceId={v.playingCloneVoice}
onPlaySample={v.handlePlayCloneSample}
CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG}
formatDuration={v.formatDuration}
/>
)}
</div>
)
}
@@ -91,7 +91,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
</span>
</div>
)}
<div className="xx-cover-preview-ratio">16:9</div>
<div className="xx-cover-preview-ratio">9:16</div>
</div>
</>
)}
@@ -1,7 +1,7 @@
/**
* 手动选择素材列表
*/
import React from "react"
import React, { useRef, useCallback } from "react"
import { Typography } from "antd"
import type { AssetItem } from "@/api/assets"
@@ -20,6 +20,33 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
selectedMaterials,
onToggle,
}) => {
// 追踪当前正在播放的视频元素,确保同时只有一个视频播放
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
const video = e.currentTarget
// 暂停之前正在播放的视频(检查是否仍在 DOM 中)
if (
activeVideoRef.current &&
activeVideoRef.current !== video &&
document.body.contains(activeVideoRef.current)
) {
activeVideoRef.current.pause()
activeVideoRef.current.currentTime = 0
}
activeVideoRef.current = video
video.play().catch(() => {})
}, [])
const handleVideoMouseLeave = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
const video = e.currentTarget
video.pause()
video.currentTime = 0
if (activeVideoRef.current === video) {
activeVideoRef.current = null
}
}, [])
return (
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
@@ -32,6 +59,8 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.items.map((m) => {
const checked = selectedMaterials.includes(m.id)
const isVideo = m.mime_type?.startsWith("video/") ?? false
const thumbSrc = m.thumbnail_url || undefined
return (
<label
key={m.id}
@@ -53,13 +82,103 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
type="checkbox"
checked={checked}
onChange={() => onToggle(m.id)}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
style={{
accentColor: "var(--primary-color, #4f46e5)",
flexShrink: 0,
}}
/>
{/* 缩略图预览 48×48 */}
<div
style={{
width: 48,
height: 48,
borderRadius: 6,
overflow: "hidden",
background: "#e2e8f0",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{isVideo && m.file_url ? (
<video
src={m.file_url}
poster={m.thumbnail_url || undefined}
muted
loop
playsInline
preload="none"
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
cursor: "pointer",
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
alt={m.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
const fallback = target.nextElementSibling as HTMLElement | null
if (fallback) fallback.style.display = "flex"
}}
/>
) : null}
{!thumbSrc && !isVideo && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "flex",
}}
>
🎵
</span>
)}
{!thumbSrc && isVideo && !m.file_url && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "flex",
}}
>
🎬
</span>
)}
{/* img onError 时显示的 fallback(初始隐藏) */}
{thumbSrc && !(isVideo && m.file_url) && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "none",
}}
>
{isVideo ? "🎬" : "🎵"}
</span>
)}
</div>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{m.name}
@@ -68,9 +187,10 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
flexShrink: 0,
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
{m.mime_type?.split("/")?.[1]?.toUpperCase() ?? "FILE"}
</span>
</label>
)
@@ -3,11 +3,13 @@
* 封装生成确认页的展示逻辑
*/
import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { getAssetsByKind } from "@/api/assets"
import { COVER_MODE_LABELS } from "../constants"
interface UseStep7GenerateProps {
@@ -39,11 +41,11 @@ export function useStep7Generate({
selectedMaterials,
smartSelectedIds,
title,
voiceMode,
voiceMode: _voiceMode,
selectedVoice,
selectedClonedVoice,
presetVoices,
clonedVoices,
selectedClonedVoice: _selectedClonedVoice,
presetVoices: _presetVoices,
clonedVoices: _clonedVoices,
coverSettings,
generateCount,
onGenerateCountChange,
@@ -65,14 +67,16 @@ export function useStep7Generate({
return `${selectedMaterials.length} 个素材`
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
// 从配音素材库中查找 voiceName
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const voiceName = useMemo(() => {
if (voiceMode === "clone") {
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
return cv ? cv.name : "未选择"
}
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
return pv ? pv.name : "未选择"
}, [voiceMode, selectedVoice, selectedClonedVoice, presetVoices, clonedVoices])
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
return asset ? asset.name : "未选择"
}, [voiceMaterials, selectedVoice])
const coverSummary = useMemo(() => {
if (!coverSettings.enabled) return "不使用"
@@ -117,11 +117,9 @@ const VoiceLibrary: React.FC = () => {
uploadOpen,
uploadFile,
uploadName,
uploadGender,
uploadDesc,
uploadProgress,
setUploadName,
setUploadGender,
setUploadDesc,
setUploadOpen,
handleFileSelect,
@@ -252,14 +250,12 @@ const VoiceLibrary: React.FC = () => {
uploadOpen={uploadOpen}
uploadFile={uploadFile}
uploadName={uploadName}
uploadGender={uploadGender}
uploadDesc={uploadDesc}
uploadProgress={uploadProgress}
onUploadClose={handleUploadClose}
onFileSelect={handleFileSelect}
onFileRemove={handleFileRemove}
onNameChange={setUploadName}
onGenderChange={setUploadGender}
onDescChange={setUploadDesc}
onUpload={handleUpload}
ttsOpen={ttsOpen}
@@ -14,14 +14,12 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
open,
uploadFile,
uploadName,
uploadGender,
uploadDesc,
uploadProgress,
onClose,
onFileSelect,
onFileRemove,
onNameChange,
onGenderChange,
onDescChange,
onUpload,
}) => {
@@ -30,7 +28,7 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
return (
<Modal
title="上传音频"
title={<span style={{ fontSize: 16, fontWeight: 600 }}>上传配音素材</span>}
open={open}
onCancel={() => {
if (uploading) return // 上传中不可关闭
@@ -45,7 +43,7 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
padding: "12px 0 4px",
}}
>
{/* 拖拽上传区 */}
@@ -64,11 +62,9 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
{/* 表单字段 */}
<FormFields
name={uploadName}
gender={uploadGender}
desc={uploadDesc}
disabled={uploading}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
onDescChange={onDescChange}
/>
@@ -2,7 +2,7 @@
* VoiceLibrary 弹窗集合
*/
import React from "react"
import type { ClonedVoiceDisplay, PresetVoiceDisplay, VoiceGender } from "../types"
import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
import type { TtsStatus } from "./TtsModal"
import CloneModal from "@/components/voice/CloneModal"
@@ -27,14 +27,12 @@ export interface VoiceModalsProps {
uploadOpen: boolean
uploadFile: File | null
uploadName: string
uploadGender: VoiceGender
uploadDesc: string
uploadProgress: number | null
onUploadClose: () => void
onFileSelect: (file: File) => void
onFileRemove: () => void
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescChange: (desc: string) => void
onUpload: () => void
@@ -67,14 +65,12 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
uploadOpen,
uploadFile,
uploadName,
uploadGender,
uploadDesc,
uploadProgress,
onUploadClose,
onFileSelect,
onFileRemove,
onNameChange,
onGenderChange,
onDescChange,
onUpload,
ttsOpen,
@@ -113,14 +109,12 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
open={uploadOpen}
uploadFile={uploadFile}
uploadName={uploadName}
uploadGender={uploadGender}
uploadDesc={uploadDesc}
uploadProgress={uploadProgress}
onClose={onUploadClose}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
onDescChange={onDescChange}
onUpload={onUpload}
/>
@@ -1,4 +1,5 @@
import React from "react"
import { Button } from "antd"
interface ActionButtonsProps {
uploading: boolean
@@ -13,12 +14,6 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
onCancel,
onUpload,
}) => {
const disabled = uploading || !canUpload
const handleUpload = () => {
onUpload()
}
return (
<div
style={{
@@ -28,39 +23,32 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
paddingTop: 4,
}}
>
<button
type="button"
<Button
onClick={onCancel}
disabled={uploading}
style={{
padding: "8px 20px",
height: 36,
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploading ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
取消
</button>
<button
type="button"
onClick={handleUpload}
disabled={disabled}
</Button>
<Button
type="primary"
onClick={onUpload}
disabled={!canUpload}
loading={uploading}
style={{
padding: "8px 20px",
height: 36,
borderRadius: 8,
border: "none",
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor: disabled ? "not-allowed" : "pointer",
minWidth: 100,
}}
>
{uploading ? "上传中..." : "开始上传"}
</button>
{uploading ? "上传中" : "开始上传"}
</Button>
</div>
)
}
@@ -10,28 +10,55 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ file }) => {
return (
<div
style={{
padding: "10px 12px",
padding: "10px 14px",
background: "var(--bg-secondary)",
borderRadius: 8,
borderRadius: 10,
display: "flex",
alignItems: "center",
gap: 10,
gap: 12,
border: "1px solid var(--border-color)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)",
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
{/* 文件图标圆形底托 */}
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
background: "var(--primary-soft, rgba(22, 119, 255, 0.1))",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<SoundOutlined style={{ fontSize: 16, color: "var(--primary-color)" }} />
</div>
{/* 文件信息 */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
lineHeight: 1.4,
}}
>
{file.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
<div
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginTop: 2,
lineHeight: 1.3,
}}
>
{formatFileSize(file.size)}
</div>
</div>
@@ -14,6 +14,8 @@ const FileUploadZone: React.FC<FileUploadZoneProps> = ({
onFileSelect,
onFileRemove,
}) => {
const [isHovered, setIsHovered] = React.useState(false)
return (
<Upload.Dragger
accept={UPLOAD_CONFIG.accept}
@@ -28,25 +30,73 @@ const FileUploadZone: React.FC<FileUploadZoneProps> = ({
showUploadList={false}
disabled={disabled}
>
<p
<div
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "20px 16px",
borderRadius: 12,
background: isHovered
? "var(--primary-soft, rgba(22, 119, 255, 0.08))"
: "var(--primary-soft, rgba(22, 119, 255, 0.03))",
border: `2px dashed ${isHovered ? "var(--primary-color)" : "var(--border-color)"}`,
transition: "all 0.25s ease",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>点击或拖拽音频文件到此处</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
支持 MP3、WAV、AAC、FLAC 等格式,最大 {UPLOAD_CONFIG.maxSizeMB}MB
</p>
{/* 图标圆形底托 */}
<div
style={{
width: 44,
height: 44,
borderRadius: "50%",
background: "var(--primary-soft, rgba(22, 119, 255, 0.1))",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 10,
transition: "transform 0.2s ease",
transform: isHovered ? "scale(1.08)" : "scale(1)",
}}
>
<UploadOutlined
style={{
fontSize: 20,
color: "var(--primary-color)",
}}
/>
</div>
{/* 主文案 */}
<p
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--text-primary)",
margin: "0 0 6px",
lineHeight: 1.4,
}}
>
点击或拖拽音频文件到此处
</p>
{/* 副文案 */}
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
lineHeight: 1.4,
}}
>
支持 MP3、WAV、AAC、FLAC 等格式,最大 {UPLOAD_CONFIG.maxSizeMB}MB
</p>
</div>
</Upload.Dragger>
)
}
@@ -1,101 +1,92 @@
import React from "react"
import type { VoiceGender } from "@/pages/voices/types"
import { GENDER_OPTIONS, UPLOAD_CONFIG } from "./types"
import { UPLOAD_CONFIG } from "./types"
interface FormFieldsProps {
name: string
gender: VoiceGender
desc: string
disabled: boolean
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescChange: (desc: string) => void
}
const labelStyle: React.CSSProperties = {
fontSize: 13,
color: "var(--text-secondary)",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary, #333)",
marginBottom: 6,
letterSpacing: "0.02em",
}
const inputStyle: React.CSSProperties = {
const baseInputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
transition: "border-color 0.2s ease, box-shadow 0.2s ease",
lineHeight: 1.5,
}
const focusStyle: React.CSSProperties = {
borderColor: "var(--primary-color)",
boxShadow: "0 0 0 2px var(--primary-soft, rgba(22, 119, 255, 0.12))",
}
const FormFields: React.FC<FormFieldsProps> = ({
name,
gender,
desc,
disabled,
onNameChange,
onGenderChange,
onDescChange,
}) => {
return (
<>
{/* 名称 */}
<div>
<div style={labelStyle}>素材名称</div>
<div style={labelStyle}>配音名称</div>
<input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
placeholder="输入配音名称"
maxLength={UPLOAD_CONFIG.maxNameLength}
disabled={disabled}
style={inputStyle}
style={baseInputStyle}
onFocus={(e) => {
Object.assign(e.target.style, focusStyle)
}}
onBlur={(e) => {
e.target.style.borderColor = "var(--border-color)"
e.target.style.boxShadow = "none"
}}
/>
</div>
{/* 性别选择 */}
<div>
<div style={labelStyle}>音色性别</div>
<div style={{ display: "flex", gap: 8 }}>
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onGenderChange(opt.value)}
disabled={disabled}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${gender === opt.value ? "var(--primary-color)" : "var(--border-color)"}`,
background: gender === opt.value ? "var(--primary-soft)" : "transparent",
color: gender === opt.value ? "var(--primary-color)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: gender === opt.value ? 600 : 400,
cursor: disabled ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div style={labelStyle}>音色描述(可选)</div>
<div style={labelStyle}>配音描述(可选)</div>
<textarea
value={desc}
onChange={(e) => onDescChange(e.target.value)}
placeholder="描述这个音色的特点..."
placeholder="描述这段配音的内容..."
maxLength={UPLOAD_CONFIG.maxDescLength}
rows={2}
disabled={disabled}
style={{
...inputStyle,
...baseInputStyle,
resize: "vertical",
fontFamily: "inherit",
minHeight: 56,
}}
onFocus={(e) => {
Object.assign(e.target.style, focusStyle)
}}
onBlur={(e) => {
e.target.style.borderColor = "var(--border-color)"
e.target.style.boxShadow = "none"
}}
/>
</div>
@@ -6,25 +6,40 @@ interface UploadProgressProps {
const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
return (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
padding: "12px 16px",
background: "var(--bg-secondary)",
borderRadius: 10,
textAlign: "center",
border: "1px solid var(--border-color)",
}}
>
<div
style={{
fontSize: 22,
fontSize: 20,
fontWeight: 700,
color: "var(--primary-color)",
lineHeight: 1.2,
}}
>
{progress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
<div
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginTop: 2,
marginBottom: 10,
}}
>
{progress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
background: "var(--bg-tertiary, #f0f0f0)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
@@ -32,7 +47,7 @@ const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
style={{
height: "100%",
width: `${progress}%`,
background: "var(--primary-color)",
background: progress < 100 ? "var(--primary-color)" : "var(--success-color, #52c41a)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
@@ -1,27 +1,17 @@
import type { VoiceGender } from "@/pages/voices/types"
export interface UploadVoiceModalProps {
open: boolean
uploadFile: File | null
uploadName: string
uploadGender: VoiceGender
uploadDesc: string
uploadProgress: number | null
onClose: () => void
onFileSelect: (file: File) => void
onFileRemove: () => void
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescChange: (desc: string) => void
onUpload: () => void
}
export const GENDER_OPTIONS: { value: VoiceGender; label: string }[] = [
{ value: "female", label: "女声" },
{ value: "male", label: "男声" },
{ value: "child", label: "童声" },
]
export const UPLOAD_CONFIG = {
maxSizeMB: 200,
maxNameLength: 100,
@@ -1,6 +1,5 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { type VoiceGender } from "../types"
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
import { getAudioDuration } from "../utils/audio"
import { buildVoiceMetadata } from "../types"
@@ -19,17 +18,11 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
const [uploadOpen, setUploadOpen] = useState(false)
const [uploadFile, setUploadFile] = useState<File | null>(null)
const [uploadName, setUploadName] = useState("")
const [uploadGender, setUploadGender] = useState<VoiceGender>("female")
const [uploadDesc, setUploadDesc] = useState("")
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File
name: string
gender: VoiceGender
description: string
}) => {
mutationFn: async (data: { file: File; name: string; description: string }) => {
setUploadProgress(0)
try {
/* 获取或创建默认配音库 */
@@ -57,7 +50,6 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildVoiceMetadata({
gender: data.gender,
description: data.description,
duration,
}),
@@ -71,8 +63,15 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
showToast("上传成功", "success")
handleUploadClose()
},
onError: (err: Error) => {
showToast(err.message || "上传失败,请重试", "error")
onError: (err: Error & { response?: { status?: number } }) => {
const status = err?.response?.status
let msg = "上传失败,请重试"
if (status && status >= 500) {
msg = "服务器暂时不可用,请稍后重试"
} else if (err?.message) {
msg = err.message
}
showToast(msg, "error")
},
})
@@ -102,10 +101,9 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
uploadMutation.mutate({
file: uploadFile,
name: uploadName.trim(),
gender: uploadGender,
description: uploadDesc.trim(),
})
}, [uploadFile, uploadName, uploadGender, uploadDesc, uploadMutation])
}, [uploadFile, uploadName, uploadDesc, uploadMutation])
return {
// 弹窗状态
@@ -114,13 +112,11 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
// 表单状态
uploadFile,
uploadName,
uploadGender,
uploadDesc,
uploadProgress,
isUploading: uploadMutation.isPending,
// Setters
setUploadName,
setUploadGender,
setUploadDesc,
// Handlers
handleFileSelect,
-1
View File
@@ -3,7 +3,6 @@ import { Navigate } from "react-router-dom"
import { useAuthStore } from "@/store/authStore"
/** 受保护的路由组件 */
// eslint-disable-next-line react-refresh/only-export-components
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
-5
View File
@@ -26,12 +26,10 @@ import { refreshAccessToken } from "@/api/auth"
import apiClient from "@/api/client"
// 从真实实例取出拦截器回调
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{
fulfilled: (config: unknown) => unknown
rejected: (error: unknown) => unknown
}>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const responseHandlers = (apiClient as any).interceptors.response.handlers as Array<{
fulfilled: (response: unknown) => unknown
rejected: (error: unknown) => Promise<unknown>
@@ -258,7 +256,6 @@ describe("apiClient - 401 token refresh", () => {
isAuthenticated: false,
clearAuth: mockClearAuth,
setAuth: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
const err = makeAxiosError(401, { detail: "Unauthorized" })
@@ -276,7 +273,6 @@ describe("apiClient - 401 token refresh", () => {
isAuthenticated: true,
clearAuth: vi.fn(),
setAuth: mockSetAuth,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
vi.mocked(refreshAccessToken).mockResolvedValue({
access_token: "new-access",
@@ -306,7 +302,6 @@ describe("apiClient - 401 token refresh", () => {
isAuthenticated: true,
clearAuth: mockClearAuth,
setAuth: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
-2
View File
@@ -32,9 +32,7 @@ afterEach(() => {
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Vi {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface Assertion<T = any> extends jest.Matchers<void, T> {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface AsymmetricMatchersContaining extends jest.Matchers<void, any> {}
}
}
+22 -13
View File
@@ -82,14 +82,12 @@ def mix_audio(
"""音频后处理混音.
处理逻辑:
1. 主音频源按优先级查找:main > broll(background 不参与主音频,通常是图片无音轨)
2. 主图层音频按顺序 concat 拼接
3. 独立音频轨(audio role)用 amix 混入
4. 输出时长截断到 video_duration
5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败
6. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
7. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
8. 如果配置了降噪,最后应用降噪
1. 丢弃主图层(main/broll/overlay/corner_voice)的原始音频,避免录入源视频杂音
2. 仅使用独立音频轨(audio role,TTS/配音)作为主音频
3. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
4. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
5. 输出时长截断到 video_duration
6. 如果配置了降噪,最后应用降噪
Args:
ctx: 渲染上下文
@@ -125,8 +123,10 @@ def mix_audio(
if "audio" in layer_map:
audio_clips = layer_map["audio"].clips
# ── 丢弃源视频的原始音频(避免录入杂音),成片仅保留 TTS 配音 + BGM ──
main_clips = []
# ── 防御:过滤掉无音频流的 clip ──
main_clips = [c for c in main_clips if clip_has_audio(ctx, c)]
audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)]
if not main_clips and not audio_clips:
@@ -144,12 +144,21 @@ def mix_audio(
# 构建音频处理命令
output_path = ctx.work_dir / f"audio_{ctx.plan_id}.aac"
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
if main_clips and not audio_clips:
concat_main_audio(ctx, main_clips, output_path, video_duration)
# 源视频原始音频已被丢弃(main_clips = []),最终音频完全由独立音频轨 + BGM + 多轨配置组成。
# 当无 main_clips 时,将独立音频轨作为主音频走 concat 拼接;当二者均有则走 amix 混音。
if main_clips:
effective_main = main_clips
effective_audio = audio_clips
else:
effective_main = audio_clips
effective_audio = []
# 简单场景:只有主音频 + 无独立音频 → 直接拼接
if effective_main and not effective_audio:
concat_main_audio(ctx, effective_main, output_path, video_duration)
else:
# 有独立音频轨 → amix 混音
mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration)
mix_with_independent_audio(ctx, effective_main, effective_audio, output_path, video_duration)
# ── BGM 混音 ──
if bgm_path and bgm_config and bgm_config.get("enabled", False):
@@ -163,6 +163,36 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
job_service.fail_job(job_id, error_msg[:500])
raise RuntimeError(result.error_message)
# 生成封面(如果配置启用)
cover_url = None
try:
from video_processing.cover_generator import generate_cover_from_plan
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository as EditPlanRepository,
)
# 获取 plan 对象
plan_repo = EditPlanRepository(db)
plan = plan_repo.get(plan_id)
if plan and result.output_path:
# 检查 cover_config
cover_config = (plan.config or {}).get("cover_config")
if cover_config and cover_config.get("enabled", False):
from pathlib import Path
output_dir = Path(result.output_path).parent
cover_path = generate_cover_from_plan(plan, result.output_path, output_dir)
if cover_path:
# 生成 cover_url(相对路径或上传到存储)
cover_url = f"/covers/{plan_id}.jpg"
logger.info("封面生成成功: plan_id=%s cover_path=%s", plan_id, cover_path)
else:
logger.info("封面生成未启用: plan_id=%s", plan_id)
except Exception as e:
logger.warning("封面生成失败(不影响视频合成): plan_id=%s error=%s", plan_id, e)
# 更新 Job 状态为完成
result_data = {
"plan_id": plan_id,
@@ -175,6 +205,7 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
"width": result.width,
"height": result.height,
"file_size": result.file_size,
"cover_url": cover_url,
}
job_service.complete_job(job_id, result=result_data)
+4 -1
View File
@@ -1,9 +1,10 @@
"""Quota system with registry pattern.
Three subscription tiers with different limits:
Four subscription tiers with different limits:
- free: 2GB storage, 5 videos/month, 3 concurrent, 3 templates, 50 titles, 10 voiceovers, no AI voice
- basic: 20GB storage, 30 videos/month, 10 concurrent, 15 templates, 500 titles, 100 voiceovers, AI voice
- premium: 100GB storage, 100 videos/month, 20 concurrent, unlimited templates, 500 titles, 100 voiceovers, AI voice
- pro: Same as premium (alias for premium tier)
Quota dimensions are registered by modules via the ModuleRegistry,
and checked against the user's subscription plan.
@@ -100,6 +101,8 @@ QUOTA_TIERS: Dict[str, QuotaTier] = {
},
),
}
# pro 套餐与 premium 配额相同,使用别名引用避免重复维护
QUOTA_TIERS["pro"] = QUOTA_TIERS["premium"]
class QuotaWarningLevel:
+5 -5
View File
@@ -354,13 +354,13 @@ class TestQuotaRegistry:
assert len(reg.list_dimensions()) == len(QuotaDimension)
def test_list_tiers(self):
"""三个套餐等级."""
"""四个套餐等级."""
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert "pro" in tiers
assert "free" in tiers
assert "basic" in tiers
assert "premium" in tiers
assert len(tiers) == 3
assert len(tiers) == 4
def test_get_tier_existing(self):
"""获取已有的套餐."""
@@ -370,7 +370,7 @@ class TestQuotaRegistry:
assert tier.name == "free"
def test_get_tier_nonexistent(self):
"""获取不存在的套餐返回 None."""
"""不存在的套餐返回 None"""
reg = QuotaRegistry()
assert reg.get_tier("enterprise") is None
@@ -380,7 +380,7 @@ class TestQuotaRegistry:
assert reg.get_limit("free", QuotaDimension.STORAGE_GB) == 2
def test_get_limit_nonexistent_plan(self):
"""不存在的套餐返回 0."""
"""不存在的套餐 fallback 到 free 配额"""
reg = QuotaRegistry()
assert reg.get_limit("enterprise", QuotaDimension.STORAGE_GB) == 0
@@ -0,0 +1,232 @@
"""测试 compose_video 任务中封面生成集成."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
import pytest
class TestComposeVideoCoverIntegration:
"""测试视频合成任务中的封面生成集成."""
@pytest.fixture
def mock_job_service(self):
"""模拟 JobService."""
service = MagicMock()
service.get_job.return_value = MagicMock(
id="job_123",
payload={"plan_id": "plan_456"},
)
return service
@pytest.fixture
def mock_db(self):
"""模拟数据库会话."""
return MagicMock()
@pytest.fixture
def mock_render_result(self):
"""模拟渲染结果."""
result = MagicMock()
result.success = True
result.output_path = Path("/tmp/output/video_123.mp4")
result.output_url = "https://example.com/video_123.mp4"
result.duration = 30.0
result.clip_count = 5
result.width = 1080
result.height = 1920
result.file_size = 1024000
return result
@pytest.fixture
def mock_plan_with_cover_enabled(self):
"""模拟启用封面的 plan."""
plan = MagicMock()
plan.id = "plan_456"
plan.config = {
"cover_config": {
"enabled": True,
"mode": "smart",
}
}
return plan
@pytest.fixture
def mock_plan_with_cover_disabled(self):
"""模拟禁用封面的 plan."""
plan = MagicMock()
plan.id = "plan_456"
plan.config = {
"cover_config": {
"enabled": False,
}
}
return plan
@pytest.fixture
def mock_plan_without_cover_config(self):
"""模拟没有 cover_config 的 plan."""
plan = MagicMock()
plan.id = "plan_456"
plan.config = {}
return plan
def test_cover_generation_called_when_enabled(
self,
mock_job_service,
mock_db,
mock_render_result,
mock_plan_with_cover_enabled,
):
"""测试封面生成在启用时被调用."""
from worker_app.tasks.compose_video import _compose_with_unified_engine
# 模拟 RenderAdapter
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
adapter_instance = MagicMock()
adapter_instance.render_plan.return_value = mock_render_result
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
MockAdapter.return_value = adapter_instance
# 模拟 EditPlanRepository
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
) as MockPlanRepo:
plan_repo_instance = MagicMock()
plan_repo_instance.get.return_value = mock_plan_with_cover_enabled
MockPlanRepo.return_value = plan_repo_instance
# 模拟 generate_cover_from_plan
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
mock_gen_cover.return_value = Path("/tmp/output/cover_plan_456.jpg")
# 执行
task = MagicMock()
result = _compose_with_unified_engine(
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
)
# 验证封面生成被调用
mock_gen_cover.assert_called_once()
call_args = mock_gen_cover.call_args
assert call_args[0][0] == mock_plan_with_cover_enabled # plan
assert call_args[0][1] == mock_render_result.output_path # video_path
assert call_args[0][2] == mock_render_result.output_path.parent # output_dir
# 验证结果包含 cover_url
assert "cover_url" in result["result"]
assert result["result"]["cover_url"] == "/covers/plan_456.jpg"
def test_cover_generation_skipped_when_disabled(
self,
mock_job_service,
mock_db,
mock_render_result,
mock_plan_with_cover_disabled,
):
"""测试封面生成在禁用时被跳过."""
from worker_app.tasks.compose_video import _compose_with_unified_engine
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
adapter_instance = MagicMock()
adapter_instance.render_plan.return_value = mock_render_result
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
MockAdapter.return_value = adapter_instance
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
) as MockPlanRepo:
plan_repo_instance = MagicMock()
plan_repo_instance.get.return_value = mock_plan_with_cover_disabled
MockPlanRepo.return_value = plan_repo_instance
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
task = MagicMock()
result = _compose_with_unified_engine(
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
)
# 验证封面生成未被调用
mock_gen_cover.assert_not_called()
# 验证结果中 cover_url 为 None
assert "cover_url" in result["result"]
assert result["result"]["cover_url"] is None
def test_cover_generation_skipped_when_no_config(
self,
mock_job_service,
mock_db,
mock_render_result,
mock_plan_without_cover_config,
):
"""测试没有 cover_config 时封面生成被跳过."""
from worker_app.tasks.compose_video import _compose_with_unified_engine
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
adapter_instance = MagicMock()
adapter_instance.render_plan.return_value = mock_render_result
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
MockAdapter.return_value = adapter_instance
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
) as MockPlanRepo:
plan_repo_instance = MagicMock()
plan_repo_instance.get.return_value = mock_plan_without_cover_config
MockPlanRepo.return_value = plan_repo_instance
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
task = MagicMock()
result = _compose_with_unified_engine(
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
)
# 验证封面生成未被调用
mock_gen_cover.assert_not_called()
# 验证结果中 cover_url 为 None
assert "cover_url" in result["result"]
assert result["result"]["cover_url"] is None
def test_cover_generation_failure_does_not_break_video(
self,
mock_job_service,
mock_db,
mock_render_result,
mock_plan_with_cover_enabled,
):
"""测试封面生成失败不影响视频合成."""
from worker_app.tasks.compose_video import _compose_with_unified_engine
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
adapter_instance = MagicMock()
adapter_instance.render_plan.return_value = mock_render_result
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
MockAdapter.return_value = adapter_instance
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
) as MockPlanRepo:
plan_repo_instance = MagicMock()
plan_repo_instance.get.return_value = mock_plan_with_cover_enabled
MockPlanRepo.return_value = plan_repo_instance
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
# 模拟封面生成抛出异常
mock_gen_cover.side_effect = Exception("FFmpeg failed")
task = MagicMock()
result = _compose_with_unified_engine(
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
)
# 验证视频合成仍然成功
assert result["status"] == "completed"
assert "result" in result
assert result["result"]["output_url"] == mock_render_result.output_url
# 验证结果中 cover_url 为 None
assert result["result"]["cover_url"] is None
+29 -1
View File
@@ -231,6 +231,7 @@ class TestQuotaRegistry:
assert tier.name == "free"
def test_get_tier_unknown_returns_none(self):
"""未知套餐返回 None"""
reg = QuotaRegistry()
assert reg.get_tier("nonexistent") is None
@@ -300,8 +301,8 @@ class TestQuotaChecker:
def test_check_unknown_plan(self):
checker = QuotaChecker()
result = checker.check("unknown", "storage_gb", 1.0)
assert result.allowed is False
assert result.limit == 0
assert not result.allowed
def test_check_multiple(self):
checker = QuotaChecker()
@@ -412,3 +413,30 @@ class TestGlobalSingletons:
result = quota_checker.check("free", "storage_gb", 1.0)
assert result.allowed is True
assert result.limit == 2
class TestProTier:
"""Pro 套餐专项测试"""
def test_pro_tier_exists(self):
"""pro 套餐存在于 QUOTA_TIERS"""
from packages.domain.quota import QUOTA_TIERS
assert "pro" in QUOTA_TIERS
def test_pro_tier_same_as_premium(self):
"""pro 套餐配额与 premium 完全一致"""
from packages.domain.quota import QUOTA_TIERS
pro = QUOTA_TIERS["pro"]
premium = QUOTA_TIERS["premium"]
assert pro.limits == premium.limits
def test_pro_tier_get_limit(self):
"""pro 套餐各维度配额正确"""
reg = QuotaRegistry()
assert reg.get_limit("pro", "storage_gb") == 100
assert reg.get_limit("pro", "videos_per_month") == 100
assert reg.get_limit("pro", "max_concurrent") == 20
assert reg.get_limit("pro", "max_titles") == 500
assert reg.get_limit("pro", "ai_voice_enabled") == 1
+2 -1
View File
@@ -185,9 +185,10 @@ class TestQuotaRegistry:
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert "free" in tiers
assert "pro" in tiers
assert "basic" in tiers
assert "premium" in tiers
assert len(tiers) == 3
assert len(tiers) == 4
def test_get_tier_existing(self):
reg = QuotaRegistry()
+180 -99
View File
@@ -976,7 +976,7 @@ class TestAudioMixing:
assert UnifiedRenderService._clip_effective_duration(clip) == 0.0
def test_mix_audio_single_main_clip(self):
"""单主clip时直接提取音频。"""
"""只有 main clip(无独立音频轨)→ 源视频音频被丢弃,返回 None。"""
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
svc = _make_service(clips, asset_paths)
@@ -990,23 +990,21 @@ class TestAudioMixing:
ctx = _make_ctx()
result = mix_audio(ctx, layers, 5.0)
assert result is not None
assert result.name == "audio_plan_001.aac"
mock_run.assert_called_once()
# 验证命令包含 -vn(无视频)和 aac 编码
cmd = mock_run.call_args[0][0]
assert "-vn" in cmd
assert "aac" in cmd
# 源视频音频被丢弃,没有独立音频轨 → 无音频
assert result is None
mock_run.assert_not_called()
def test_mix_audio_multi_main_clips(self):
"""多主clip时用concat拼接音频。"""
"""多个独立音频轨用 concat 拼接(main 图层音频被丢弃)。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=2.0),
_make_clip("c1", "main", order=0, duration=3.0), # 源视频音频被丢弃
_make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}),
_make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
"asset_tts2.mp4": Path("/tmp/asset_tts2.mp4"),
}
svc = _make_service(clips, asset_paths)
@@ -1022,15 +1020,16 @@ class TestAudioMixing:
assert result is not None
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# 验证有 filter_complex 和 concat
assert "-filter_complex" in cmd
cmd_str = " ".join(cmd)
# 2 个独立音频轨 concat
assert "concat=n=2:v=0:a=1" in cmd_str
# main 的 c1 不参与音频(源视频杂音被丢弃)
assert "asset_c1.mp4" not in cmd_str
def test_mix_audio_with_independent_audio_track(self):
"""有独立音频轨时用amix混音。"""
"""独立音频轨生效;main 图层源视频音频被丢弃。"""
clips = [
_make_clip("c1", "main", order=0, duration=5.0),
_make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃
_make_clip(
"bgm1",
"main",
@@ -1055,11 +1054,52 @@ class TestAudioMixing:
result = mix_audio(ctx, layers, 5.0)
assert result is not None
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
cmd_str = " ".join(cmd)
# main 的源视频 c1 不参与音频
assert "asset_c1.mp4" not in cmd_str
# 独立音频轨 bgm1 作为最终音频生效
assert "asset_bgm1.mp4" in cmd_str
def test_mix_with_independent_audio_amix(self):
"""直接验证 mix_with_independent_audio:main 音频 + 独立音频轨走 amix 混音。"""
from video_processing.render_audio import mix_with_independent_audio
from video_processing.unified_render_service import ResolvedClip
main = ResolvedClip(
clip_id="c1",
asset_id="asset_c1",
clip_type="main",
order=0,
local_path=Path("/tmp/asset_c1.mp4"),
duration=5.0,
actual_duration=5.0,
config={},
)
aud = ResolvedClip(
clip_id="bgm1",
asset_id="asset_bgm1",
clip_type="main",
order=1,
local_path=Path("/tmp/asset_bgm1.mp4"),
duration=5.0,
actual_duration=5.0,
config={"volume": 0.5},
)
ctx = _make_ctx()
output_path = ctx.work_dir / "audio_plan_001.aac"
with patch("video_processing.render_audio.run_ffmpeg") as mock_run:
mix_with_independent_audio(ctx, [main], [aud], output_path, 5.0)
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
cmd_str = " ".join(cmd)
assert "amix" in cmd_str
assert "volume=0.5" in cmd_str
assert "asset_c1.mp4" in cmd_str
assert "asset_bgm1.mp4" in cmd_str
def test_mix_audio_no_audio_returns_none(self):
"""没有音频素材时返回None。"""
@@ -1082,14 +1122,16 @@ class TestAudioMixing:
assert result is None
def test_mix_audio_background_not_used_as_main(self):
"""background 图层不参与主音频,main 优先级更高。"""
"""main/background 图层音频都被丢弃,只有独立音频轨参与混音。"""
clips = [
_make_clip("bg1", "background", order=0, duration=5.0),
_make_clip("c1", "main", order=0, duration=5.0),
_make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}),
]
asset_paths = {
"asset_bg1.mp4": Path("/tmp/asset_bg1.mp4"),
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
}
svc = _make_service(clips, asset_paths)
@@ -1105,21 +1147,24 @@ class TestAudioMixing:
assert result is not None
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# 验证主音频源是 main 的 c1,不是 background 的 bg1
# 单 main clip 走直接提取路径,输入文件应该只有 c1
cmd_str = " ".join(cmd)
assert "asset_c1.mp4" in cmd_str
# 源视频(background + main)音频都被丢弃
assert "asset_bg1.mp4" not in cmd_str
assert "asset_c1.mp4" not in cmd_str
# 只有独立音频轨
assert "asset_tts1.mp4" in cmd_str
def test_mix_audio_main_priority_over_broll(self):
"""main 图层优先级高于 broll。"""
"""main/broll 图层的源视频音频都被丢弃,只使用独立音频轨。"""
clips = [
_make_clip("b1", "b_roll", order=0, duration=5.0),
_make_clip("c1", "main", order=0, duration=5.0),
_make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}),
]
asset_paths = {
"asset_b1.mp4": Path("/tmp/asset_b1.mp4"),
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
}
svc = _make_service(clips, asset_paths)
@@ -1136,12 +1181,13 @@ class TestAudioMixing:
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
cmd_str = " ".join(cmd)
# 主音频源应该是 main 的 c1,不是 broll 的 b1
assert "asset_c1.mp4" in cmd_str
# main 和 broll 的源视频音频都被丢弃
assert "asset_c1.mp4" not in cmd_str
assert "asset_b1.mp4" not in cmd_str
assert "asset_tts1.mp4" in cmd_str
def test_mix_audio_broll_used_when_no_main(self):
"""没有 main 时,broll 作为主音频源。"""
"""broll 图层源视频音频也被丢弃;无独立音频轨 → 返回 None。"""
clips = [_make_clip("b1", "b_roll", order=0, duration=5.0)]
asset_paths = {"asset_b1.mp4": Path("/tmp/asset_b1.mp4")}
svc = _make_service(clips, asset_paths)
@@ -1155,16 +1201,20 @@ class TestAudioMixing:
ctx = _make_ctx()
result = mix_audio(ctx, layers, 5.0)
assert result is not None
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "-vn" in cmd
assert "asset_b1.mp4" in " ".join(cmd)
# 源视频音频被丢弃,无独立音频轨 → 无音频
assert result is None
mock_run.assert_not_called()
def test_mix_audio_single_clip_truncated_to_video_duration(self):
"""单clip音频截断到 video_duration(video_duration < clip有效时长)。"""
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
"""单独立音频轨截断到 video_duration(video_duration < clip有效时长)。"""
clips = [
_make_clip("c1", "main", order=0, duration=10.0), # 源视频音频被丢弃
_make_clip("tts1", "main", order=0, duration=10.0, config={"role": "audio"}),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
}
svc = _make_service(clips, asset_paths)
with (
@@ -1174,17 +1224,17 @@ class TestAudioMixing:
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
# video_duration 只有 3.0,小于 clip 的 10.0
# video_duration 只有 3.0,小于独立音频轨的 10.0
result = mix_audio(ctx, layers, 3.0)
assert result is not None
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# 验证 -t 参数是 3.0 不是 10.0
t_index = cmd.index("-t")
assert t_index >= 0
t_value = float(cmd[t_index + 1])
assert t_value == 3.0
# 验证截断到 3.0(-t 3.0 或 atrim=0:3.000)
cmd_str = " ".join(cmd)
assert "3.000" in cmd_str or "3.0" in cmd_str
# 源视频不参与
assert "asset_c1.mp4" not in cmd_str
def test_merge_audio_video(self):
"""合并音视频命令正确。"""
@@ -1341,25 +1391,22 @@ class TestAudioMixing:
mock_run.assert_not_called()
def test_mix_audio_partial_clips_no_audio_filtered(self):
"""部分主图层clip无音频流时,过滤掉无音轨的,剩余有音频的正常concat。"""
"""main 图层音频全部丢弃;独立音频轨有/无音频时按预期过滤。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0), # 无音频
_make_clip("c2", "main", order=1, duration=2.0), # 有音频
_make_clip("c1", "main", order=0, duration=3.0), # 源视频音频被丢弃
_make_clip("c2", "main", order=1, duration=2.0), # 源视频音频被丢弃
_make_clip("tts1", "main", order=0, duration=2.0, config={"role": "audio"}), # 独立音频轨
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
}
svc = _make_service(clips, asset_paths)
# 模拟:c1 无音频,c2 有音频
def fake_has_audio(path):
return "c2" in str(path)
with (
_patch_path_exists(),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch("video_processing.render_audio.probe_has_audio", side_effect=fake_has_audio),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
@@ -1370,21 +1417,23 @@ class TestAudioMixing:
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
cmd_str = " ".join(cmd)
# 只剩 1 个有效音频 clip,走单clip路径(-vn),不走 filter_complex concat
assert "-vn" in cmd
assert "concat=n=2" not in cmd_str
# 只有独立音频轨 tts1 参与
assert "asset_tts1.mp4" in cmd_str
# main 的 c1/c2 源视频音频被丢弃
assert "asset_c1.mp4" not in cmd_str
assert "asset_c2.mp4" not in cmd_str
def test_mix_audio_all_main_no_audio_but_independent_track(self):
"""主图层全部无音频,但有独立音频轨时,正常走amix混音。"""
"""main 图层源视频音频全部丢弃;仅独立音频轨生效,走 concat 单轨路径。"""
clips = [
_make_clip("c1", "main", order=0, duration=5.0), # 无音频
_make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃
_make_clip(
"bgm1",
"main",
order=0,
duration=5.0,
config={"role": "audio", "volume": 0.5},
), # 独立音频轨(有音频)
), # 独立音频轨
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
@@ -1392,13 +1441,9 @@ class TestAudioMixing:
}
svc = _make_service(clips, asset_paths)
def fake_has_audio(path):
return "bgm" in str(path)
with (
_patch_path_exists(),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch("video_processing.render_audio.probe_has_audio", side_effect=fake_has_audio),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
@@ -1409,8 +1454,10 @@ class TestAudioMixing:
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
cmd_str = " ".join(cmd)
# 只有独立音频轨参与混音,amix 输入数=1
assert "amix=inputs=1" in cmd_str
# main 的源视频 c1 不参与音频
assert "asset_c1.mp4" not in cmd_str
# 只有独立音频轨 bgm1 作为主音频走单轨拼接
assert "asset_bgm1.mp4" in cmd_str
def test_mix_audio_both_no_audio_returns_none(self):
"""主图层和独立音频轨都无音频时,返回None。"""
@@ -2069,14 +2116,16 @@ class TestConcatNormalizeAudioFormat:
"""
def test_multi_clip_concat_has_aformat(self):
"""多 clip 音频 concat 前,每个 clip 都有 aformat 归一化。"""
"""多独立音频轨 concat 前,每个轨都有 aformat 归一化(main 图层源视频音频被丢弃)。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=2.0),
_make_clip("c1", "main", order=0, duration=3.0), # 源视频音频被丢弃
_make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}),
_make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
"asset_tts2.mp4": Path("/tmp/asset_tts2.mp4"),
}
svc = _make_service(clips, asset_paths)
@@ -2099,22 +2148,24 @@ class TestConcatNormalizeAudioFormat:
assert "channel_layouts=stereo" in cmd_str, "声道应统一为 stereo"
assert "sample_fmts=fltp" in cmd_str, "采样格式应统一为 fltp"
# 两个 clip 都应该有 aformat
# 2 个独立音频轨都应有 aformat
aformat_count = cmd_str.count("aformat=")
assert aformat_count >= 2, f"每个 clip 都应有 aformat,实际 {aformat_count} 个"
assert aformat_count >= 2, f"每个独立音频轨都应有 aformat,实际 {aformat_count} 个"
# 有 concat
assert "concat=n=2:v=0:a=1" in cmd_str
# main 源视频不参与
assert "asset_c1.mp4" not in cmd_str
def test_aformat_before_concat(self):
"""aformat 应在 concat 之前(每个 clip 处理链中 aformat 在 concat 之前)。"""
"""aformat 应在 concat 之前(每个独立音频轨处理链中 aformat 在 concat 之前)。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=2.0),
_make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}),
_make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
"asset_tts2.mp4": Path("/tmp/asset_tts2.mp4"),
}
svc = _make_service(clips, asset_paths)
@@ -2131,19 +2182,23 @@ class TestConcatNormalizeAudioFormat:
fc_idx = cmd.index("-filter_complex")
fc_str = cmd[fc_idx + 1]
# 找到 concat 的位置
concat_pos = fc_str.find("concat=")
assert concat_pos > 0, "应找到 concat filter"
# 在 concat 之前的部分应该有 aformat
before_concat = fc_str[:concat_pos]
aformat_before_count = before_concat.count("aformat=")
assert aformat_before_count >= 2, f"concat 之前每个 clip 都应有 aformat,实际 {aformat_before_count} 个"
assert aformat_before_count >= 2, f"concat 之前每个独立音频轨都应有 aformat,实际 {aformat_before_count} 个"
def test_single_clip_audio_has_normalized_output(self):
"""单 clip 音频输出也应统一格式(一致性保障)。"""
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
"""单独立音频轨输出也应统一格式(一致性保障)。"""
clips = [
_make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃
_make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
}
svc = _make_service(clips, asset_paths)
with (
@@ -2158,18 +2213,20 @@ class TestConcatNormalizeAudioFormat:
assert mock_run.called
cmd = mock_run.call_args[0][0]
# 单 clip 简单路径应该有 -ar 48000 和 -ac 2
assert "-ar" in cmd, "单 clip 音频应指定采样率"
# 单独立音频轨简单路径应有 -ar 48000 和 -ac 2
assert "-ar" in cmd, "单独立音频轨应指定采样率"
ar_idx = cmd.index("-ar")
assert cmd[ar_idx + 1] == "48000", "采样率应为 48000"
assert "-ac" in cmd, "单 clip 音频应指定声道数"
assert "-ac" in cmd, "单独立音频轨应指定声道数"
ac_idx = cmd.index("-ac")
assert cmd[ac_idx + 1] == "2", "声道数应为 2(stereo)"
cmd_str = " ".join(cmd)
assert "asset_c1.mp4" not in cmd_str
def test_independent_audio_track_has_aformat(self):
"""独立音频轨在 amix 前也有 aformat 归一化。"""
"""独立音频轨输出也应统一格式(48000Hz + stereo + aac)。"""
clips = [
_make_clip("c1", "main", order=0, duration=5.0),
_make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃
_make_clip(
"audio1",
"main",
@@ -2197,12 +2254,16 @@ class TestConcatNormalizeAudioFormat:
cmd = mock_run.call_args[0][0]
cmd_str = " ".join(cmd)
# 应该有 aformat
assert "aformat=" in cmd_str
assert "sample_rates=48000" in cmd_str
assert "channel_layouts=stereo" in cmd_str
# amix 应该存在
assert "amix" in cmd_str
# 单独立音频轨走 concat 单轨简单路径:-ar 48000 -ac 2
assert "-ar" in cmd
ar_idx = cmd.index("-ar")
assert cmd[ar_idx + 1] == "48000"
assert "-ac" in cmd
ac_idx = cmd.index("-ac")
assert cmd[ac_idx + 1] == "2"
# main 源视频 c1 不参与音频
assert "asset_c1.mp4" not in cmd_str
assert "asset_audio1.mp4" in cmd_str
class TestConcatNormalizeAudioCodec:
@@ -2212,14 +2273,14 @@ class TestConcatNormalizeAudioCodec:
"""
def test_multi_clip_output_is_aac(self):
"""多 clip concat 后输出编码为 aac。"""
"""多独立音频轨 concat 后输出编码为 aac。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=2.0),
_make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}),
_make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
"asset_tts2.mp4": Path("/tmp/asset_tts2.mp4"),
}
svc = _make_service(clips, asset_paths)
@@ -2238,9 +2299,15 @@ class TestConcatNormalizeAudioCodec:
assert "-b:a" in cmd, "应指定音频码率"
def test_single_clip_output_is_aac(self):
"""单 clip 音频输出编码为 aac。"""
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
"""单独立音频轨输出编码为 aac。"""
clips = [
_make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃
_make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
}
svc = _make_service(clips, asset_paths)
with (
@@ -2255,6 +2322,7 @@ class TestConcatNormalizeAudioCodec:
assert mock_run.called
cmd = mock_run.call_args[0][0]
assert "aac" in cmd
assert "asset_c1.mp4" not in " ".join(cmd)
class TestConcatNormalizeFourItemsComplete:
@@ -2304,9 +2372,14 @@ class TestConcatNormalizeFourItemsComplete:
assert fps_count >= 3, f"3个 clip 都应有 fps=30,实际 {fps_count} 个"
def test_audio_format_normalized(self):
"""[3/4] 音频格式:所有 clip concat 前都有 aformat 归一化。"""
clips = self._make_one_take_clips()
"""[3/4] 音频格式:3 个独立音频轨 concat 前都有 aformat 归一化(main 图层源视频音频被丢弃)。"""
clips = self._make_one_take_clips() + [
_make_clip("tts1", "main", order=10, duration=5.0, config={"role": "audio"}),
_make_clip("tts2", "main", order=11, duration=4.0, config={"role": "audio"}),
_make_clip("tts3", "main", order=12, duration=3.0, config={"role": "audio"}),
]
asset_paths = {f"asset_c{i}.mp4": Path(f"/tmp/asset_c{i}.mp4") for i in range(1, 4)}
asset_paths.update({f"asset_tts{i}.mp4": Path(f"/tmp/asset_tts{i}.mp4") for i in range(1, 4)})
svc = _make_service(clips, asset_paths)
with (
@@ -2323,15 +2396,23 @@ class TestConcatNormalizeFourItemsComplete:
cmd_str = " ".join(cmd)
aformat_count = cmd_str.count("aformat=")
assert aformat_count >= 3, f"3个 clip 都应有 aformat,实际 {aformat_count} 个"
assert aformat_count >= 3, f"3个独立音频轨都应有 aformat,实际 {aformat_count} 个"
assert "sample_rates=48000" in cmd_str
assert "channel_layouts=stereo" in cmd_str
assert "sample_fmts=fltp" in cmd_str
# main 图层源视频不参与
for i in range(1, 4):
assert f"asset_c{i}.mp4" not in cmd_str
def test_audio_codec_aac(self):
"""[4/4] 音频编码:输出为 aac。"""
clips = self._make_one_take_clips()
"""[4/4] 音频编码:输出为 aac(仅独立音频轨参与)。"""
clips = self._make_one_take_clips() + [
_make_clip("tts1", "main", order=10, duration=5.0, config={"role": "audio"}),
_make_clip("tts2", "main", order=11, duration=4.0, config={"role": "audio"}),
_make_clip("tts3", "main", order=12, duration=3.0, config={"role": "audio"}),
]
asset_paths = {f"asset_c{i}.mp4": Path(f"/tmp/asset_c{i}.mp4") for i in range(1, 4)}
asset_paths.update({f"asset_tts{i}.mp4": Path(f"/tmp/asset_tts{i}.mp4") for i in range(1, 4)})
svc = _make_service(clips, asset_paths)
with (