Compare commits

...

16 Commits

Author SHA1 Message Date
xiaoxia 862340ace5 fix(ci): 修复CI Gate失败时返回exit 0而非exit 1的P0 Bug
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m44s
AI Code Review / AI Code Review (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m54s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 37s
2026-07-28 10:13:32 +08:00
xiaoxia b40ae9cec7 refactor(voices): 拆分 TtsModal 为子组件(258→53行, -79%) (#1051)
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-07-28 10:03:16 +08:00
xiaoxia eb636b8fef test(wave136): 多轨混音纯逻辑抽离 + 71单测 (#1050)
CI/CD Pipeline / Check if frontend-only change (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (push) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (push) Waiting to run
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Waiting to run
CI/CD Pipeline / PR Build Web Image (push) Waiting to run
CI/CD Pipeline / PR Build Worker Image (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Build Production API Image (push) Waiting to run
CI/CD Pipeline / Build Production Web Image (push) Waiting to run
CI/CD Pipeline / Build Production Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- 新增纯逻辑模块 multi_track_mixer_pure.py,0 FFmpeg 依赖
- 71个单元测试全绿
- 向后兼容,原 multi_track_mixer.py 未修改
2026-07-28 09:53:23 +08:00
xiaoxia 09b069a65d refactor(upload-voice-modal): 三阶段重构 UploadVoiceModal(329→87行, -74%)(#1047)
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-07-28 09:43:08 +08:00
xiaoxia 378e4c8751 test(wave135): 视频拼接引擎纯逻辑抽离 + 70单测 (#1049) 2026-07-28 09:42:55 +08:00
xiaoxia f9afdd95ce test(wave133): BGM混音纯逻辑抽离 + 84单测 (#1046) 2026-07-28 09:42:49 +08:00
xiaoxia 456718ad84 test(wave134): 贴纸引擎纯逻辑抽离 + 113单测 (#1048)
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Failing after 13m7s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 13m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 12m57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Failing after 4m22s
CI/CD Pipeline / Integration Tests (push) Successful in 2m29s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m33s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 24s
CI/CD Pipeline / CI Gate (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 09:34:21 +08:00
xiaoxia edd4b6b1ea feat(ci): 格式自动修复对所有PR开放 + 防循环机制(develop同步) (#1044)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m44s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m26s
CI/CD Pipeline / Unit Tests (push) Failing after 5m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m52s
CI/CD Pipeline / Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 20m44s
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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 09:09:58 +08:00
xiaoxia 4578b65965 refactor(asset-selector): 三阶段重构 AssetSelector(504→168行, -67%) (#937)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m37s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m18s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m17s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m33s
CI/CD Pipeline / Integration Tests (push) Successful in 2m22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m19s
CI/CD Pipeline / Unit Tests (push) Failing after 6m57s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m31s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 16s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m59s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 01:02:50 +08:00
CI Bot e4b3af78b9 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m48s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m41s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m52s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m54s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m15s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 4m17s
CI/CD Pipeline / Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 7m30s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-07-27 14:39:55 +00:00
xiaoxia ff827aba01 refactor(useGenerateVideo): 拆分 useGenerateVideo Hook(370→221行, -40%) (#969)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m11s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m10s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m56s
CI/CD Pipeline / Unit Tests (push) Failing after 10m47s
CI/CD Pipeline / Integration Tests (push) Successful in 5m29s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m56s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m0s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m58s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 6m14s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m5s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-27 22:36:45 +08:00
xiaoxia a75cce0a3b feat(ci): AI代码审查添加阻塞级判定 + 门禁控制(develop同步) (#1042)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m54s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 4m54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m59s
CI/CD Pipeline / Integration Tests (push) Successful in 3m25s
CI/CD Pipeline / Unit Tests (push) Failing after 9m2s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 9m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-27 22:22:34 +08:00
xiaoxia b051acc8b4 refactor(editing-planner): 拆分 GenerationProgressModal,按阶段拆为 3 个子组件 (#1017)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m59s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Failing after 6m44s
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-27 21:58:54 +08:00
xiaoxia 271db4e989 refactor(useGenerateVideo): 拆分 useGenerateVideo Hook(370→221行, -40%) (#969)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m48s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m7s
CI/CD Pipeline / Unit Tests (push) Failing after 5m59s
CI/CD Pipeline / Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m16s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m46s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- 按职责拆分为 5 个子模块:phase/errorUtils/types/useGenerationPolling/voiceConfig
- 主 Hook 从 370 行精简到 221 行,可读性提升 40%
- 保持原有功能不变,纯重构
2026-07-27 21:41:38 +08:00
CI Bot 9eca947f88 style: auto-format with black + isort + prettier
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-07-27 13:33:18 +00:00
xiaoxia 8d3647f414 refactor(voice-materials): 深化拆分 UI 组件(603→338行, -44%) (#935)
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Failing after 8m7s
CI/CD Pipeline / Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m26s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 13m17s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10m20s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-27 21:29:17 +08:00
84 changed files with 7658 additions and 1952 deletions
+1 -1
View File
@@ -1805,7 +1805,7 @@ jobs:
echo "❌ CI Gate: FAILED"
echo "失败项: ${FAILED_ITEMS[*]}"
echo "gate_result=failure" >> $GITHUB_OUTPUT
exit 0
exit 1
fi
- name: Report CI trace
+4 -2
View File
@@ -48,6 +48,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REPO_NAME: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
LLM_PROVIDER: "coze"
# 扣子模式配置(默认国内站 api.coze.cn
@@ -60,8 +61,9 @@ jobs:
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 审查脚本异常不影响 CI 通过
continue-on-error: true
# 注意:脚本退出码决定job状态
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open
- name: Report CI trace
if: always()
@@ -0,0 +1,110 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration, getQualityLevel } from "./utils"
interface AssetCardProps {
asset: MediaAsset
isSelected: boolean
isDragging: boolean
isDragOver: boolean
showCheckbox: boolean
compact?: boolean
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
onDragEnd: () => void
onMouseEnter: (e: React.MouseEvent) => void
onMouseLeave: () => void
}
const AssetCard: React.FC<AssetCardProps> = ({
asset,
isSelected,
isDragging,
isDragOver,
showCheckbox,
onToggleSelect,
onCardClick,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onMouseEnter,
onMouseLeave,
}) => {
const qualityLevel = getQualityLevel(asset.quality_score)
return (
<div
className={[
"as-card",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
showCheckbox ? "has-checkbox" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
onClick={(e) => onCardClick(asset, e)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
{/* Checkbox */}
{showCheckbox && (
<span
data-checkbox
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset, e.shiftKey)
}}
/>
)}
{/* 类型角标 */}
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
{/* 时长角标 */}
{asset.duration != null && (
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
)}
{/* 质量分角标 */}
{asset.quality_score != null && (
<span
className={`as-card-quality ${qualityLevel}`}
title={`质量分: ${asset.quality_score}`}
>
{asset.quality_score}
</span>
)}
</div>
{/* 信息 */}
<div className="as-card-info">
<p className="as-card-name" title={asset.name}>
{asset.name}
</p>
<div className="as-card-meta">{formatSize(asset.size)}</div>
</div>
</div>
)
}
export default AssetCard
@@ -0,0 +1,99 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration, getQualityColor } from "./utils"
interface AssetListItemProps {
asset: MediaAsset
isSelected: boolean
isDragging: boolean
isDragOver: boolean
showCheckbox: boolean
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
onDragEnd: () => void
onMouseEnter: (e: React.MouseEvent) => void
onMouseLeave: () => void
}
const AssetListItem: React.FC<AssetListItemProps> = ({
asset,
isSelected,
isDragging,
isDragOver,
showCheckbox,
onToggleSelect,
onCardClick,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onMouseEnter,
onMouseLeave,
}) => {
return (
<div
className={[
"as-list-item",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
onClick={(e) => onCardClick(asset, e)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 拖拽手柄 */}
<span className="as-list-item-drag" title="拖拽排序">
</span>
{/* Checkbox */}
{showCheckbox && (
<span
data-checkbox
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset, e.shiftKey)
}}
/>
)}
{/* 图标 */}
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
{/* 信息 */}
<div className="as-list-item-info">
<div className="as-list-item-name">{asset.name}</div>
<div className="as-list-item-meta">
{MATERIAL_TYPE_LABELS[asset.type]}
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
{asset.size != null && ` · ${formatSize(asset.size)}`}
</div>
</div>
{/* 质量分 */}
{asset.quality_score != null && (
<span
className="as-list-item-quality"
style={{ color: getQualityColor(asset.quality_score) }}
>
{asset.quality_score}
</span>
)}
</div>
)
}
export default AssetListItem
@@ -9,73 +9,20 @@
* - 筛选增强:类型筛选 + 质量分筛选
* - 视图切换:网格视图 / 列表视图
*/
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
import React, { useCallback } from "react"
import "./AssetSelector.css"
import { Input, Select, Button } from "@/components/ui"
import type { AssetSelectorProps } from "./types"
import useAssetFilter from "./hooks/useAssetFilter"
import useAssetSelection from "./hooks/useAssetSelection"
import useDragReorder from "./hooks/useDragReorder"
import useAssetPreview from "./hooks/useAssetPreview"
import SelectorToolbar from "./SelectorToolbar"
import SelectorBatchBar from "./SelectorBatchBar"
import AssetCard from "./AssetCard"
import AssetListItem from "./AssetListItem"
import PreviewOverlay from "./PreviewOverlay"
import EmptyState from "./EmptyState"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
/* ──────────── 类型 ──────────── */
export interface AssetSelectorProps {
assets: MediaAsset[]
selectedIds?: string[]
onSelectionChange?: (ids: string[]) => void
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
showQualityFilter?: boolean
showBatchSelect?: boolean
compact?: boolean
}
type ViewMode = "grid" | "list"
/* ──────────── 工具函数 ──────────── */
/** 格式化文件大小 */
const formatSize = (bytes?: number): string => {
if (!bytes) return ""
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
/** 格式化时长 */
const formatDuration = (seconds?: number): string => {
if (!seconds) return ""
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
}
/** 获取质量分等级 */
const getQualityLevel = (score?: number): string => {
if (score == null) return "none"
if (score >= 90) return "excellent"
if (score >= 70) return "good"
if (score >= 50) return "fair"
return "poor"
}
/** 质量分颜色 */
const getQualityColor = (score?: number): string => {
if (score == null) return "var(--text-secondary)"
if (score >= 90) return "var(--success-color, #10b981)"
if (score >= 70) return "var(--primary-color, #6366f1)"
if (score >= 50) return "var(--warning-color, #f59e0b)"
return "var(--error-color, #ef4444)"
}
/* ──────────── 类型筛选选项 ──────────── */
const TYPE_OPTIONS = [
{ value: "", label: "全部类型" },
{ value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" },
]
/* ──────────── 组件 ──────────── */
const AssetSelector: React.FC<AssetSelectorProps> = ({
assets,
@@ -87,162 +34,41 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
showBatchSelect = true,
compact = false,
}) => {
/* ── 搜索 & 筛选 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState("")
const [filterQuality, setFilterQuality] = useState("")
const [viewMode, setViewMode] = useState<ViewMode>("grid")
// 搜索 & 筛选
const {
searchText,
filterType,
filterQuality,
viewMode,
filteredAssets,
setSearchText,
setFilterType,
setFilterQuality,
setViewMode,
} = useAssetFilter(assets)
/* ── 拖拽状态 ── */
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
// 选中操作
const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({
filteredAssets,
selectedIds,
onSelectionChange,
})
/* ── 悬浮预览 ── */
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// 拖拽排序
const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } =
useDragReorder({
filteredAssets,
selectedIds,
onAssetDragStart,
onReorder,
})
/* ── Shift 连选 ── */
const lastClickedIdx = useRef<number | null>(null)
/* ── 过滤后的素材列表 ── */
const filteredAssets = useMemo(() => {
let list = assets
if (searchText) {
const q = searchText.toLowerCase()
list = list.filter(
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
)
}
if (filterType) {
list = list.filter((a) => a.type === filterType)
}
if (filterQuality) {
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
if (opt?.min != null && opt?.max != null) {
list = list.filter(
(a) =>
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
)
}
}
return list
}, [assets, searchText, filterType, filterQuality])
/* ── 选中状态 ── */
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
/* ── 选择操作 ── */
const toggleSelect = useCallback(
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
if (!onSelectionChange) return
if (shiftKey && lastClickedIdx.current !== null) {
// Shift 连选
const start = Math.min(lastClickedIdx.current, idx)
const end = Math.max(lastClickedIdx.current, idx)
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
const newSet = new Set(selectedIds)
rangeIds.forEach((id) => newSet.add(id))
onSelectionChange(Array.from(newSet))
} else {
const newSet = new Set(selectedIds)
if (newSet.has(asset.id)) {
newSet.delete(asset.id)
} else {
newSet.add(asset.id)
}
onSelectionChange(Array.from(newSet))
}
lastClickedIdx.current = idx
},
[onSelectionChange, selectedIds, filteredAssets],
)
const clearSelection = useCallback(() => {
onSelectionChange?.([])
}, [onSelectionChange])
/* ── 拖拽排序 ── */
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
setDragIdx(idx)
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", String(idx))
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
// 批量拖拽:如果有多个选中素材,一起携带
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
}
// 通知父组件素材拖拽开始
if (onAssetDragStart) {
onAssetDragStart(filteredAssets[idx])
}
},
[filteredAssets, selectedIds, onAssetDragStart],
)
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
if (dragIdx === null || dragIdx === idx) return
setDragOverIdx(idx)
},
[dragIdx],
)
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
onReorder(dragIdx, toIdx)
}
setDragIdx(null)
setDragOverIdx(null)
},
[dragIdx, onReorder],
)
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
/* ── 悬浮预览 ── */
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
if (previewTimer.current) clearTimeout(previewTimer.current)
previewTimer.current = setTimeout(() => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setPreviewAsset(asset)
setPreviewPos({
x: rect.right + 12,
y: Math.max(8, rect.top - 20),
})
}, 400)
}, [])
const handleMouseLeave = useCallback(() => {
if (previewTimer.current) {
clearTimeout(previewTimer.current)
previewTimer.current = null
}
setPreviewAsset(null)
}, [])
// 清理定时器
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current)
}
}, [])
// 悬浮预览
const { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview()
/* ── 点击卡片 ── */
const handleCardClick = useCallback(
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
// 如果点击的是 checkbox 区域,不触发卡片点击
const target = e.target as HTMLElement
if (target.closest("[data-checkbox]")) return
toggleSelect(asset, idx, e.shiftKey)
@@ -250,253 +76,82 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
[toggleSelect],
)
/* ──────────── 渲染 ──────────── */
const hasSelection = selectedIds.length > 0
return (
<div className="as-container">
{/* ═══ 工具栏 ═══ */}
<div className="as-toolbar">
<div className="as-toolbar-left">
<div className="as-search">
<Input
placeholder="搜索素材..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
prefix="🔍"
/>
</div>
<Select
value={filterType}
onChange={(v: string) => setFilterType(v)}
options={TYPE_OPTIONS}
/>
{showQualityFilter && (
<Select
value={filterQuality}
onChange={(v: string) => setFilterQuality(v)}
options={QUALITY_OPTIONS}
/>
)}
</div>
<div className="as-toolbar-right">
<button
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
onClick={() => setViewMode("grid")}
title="网格视图"
>
</button>
<button
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => setViewMode("list")}
title="列表视图"
>
</button>
</div>
</div>
{/* 工具栏 */}
<SelectorToolbar
searchText={searchText}
filterType={filterType}
filterQuality={filterQuality}
viewMode={viewMode}
showQualityFilter={showQualityFilter}
onSearchChange={setSearchText}
onTypeChange={setFilterType}
onQualityChange={setFilterQuality}
onViewModeChange={setViewMode}
/>
{/* ═══ 批量操作栏 ═══ */}
{/* 批量操作栏 */}
{showBatchSelect && hasSelection && (
<div className="as-batch-bar">
<span className="as-batch-bar-count"> {selectedIds.length} </span>
<div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
</Button>
</div>
</div>
<SelectorBatchBar selectedCount={selectedIds.length} onClear={clearSelection} />
)}
{/* ═══ 素材列表 ═══ */}
{/* 素材列表 */}
<div className="as-body">
{filteredAssets.length === 0 ? (
<div className="as-empty">
<div className="as-empty-icon">📂</div>
<p></p>
</div>
<EmptyState />
) : viewMode === "grid" ? (
/* ── 网格视图 ── */
/* 网格视图 */
<div className={`as-grid${compact ? " compact" : ""}`}>
{filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id)
const isDragging = dragIdx === idx
const isDragOver = dragOverIdx === idx
const qualityLevel = getQualityLevel(asset.quality_score)
return (
<div
key={asset.id}
className={[
"as-card",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
showBatchSelect ? "has-checkbox" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onClick={(e) => handleCardClick(asset, idx, e)}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
{/* Checkbox */}
{showBatchSelect && (
<span
data-checkbox
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
toggleSelect(asset, idx, e.shiftKey)
}}
/>
)}
{/* 类型角标 */}
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
{/* 时长角标 */}
{asset.duration != null && (
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
)}
{/* 质量分角标 */}
{asset.quality_score != null && (
<span
className={`as-card-quality ${qualityLevel}`}
title={`质量分: ${asset.quality_score}`}
>
{asset.quality_score}
</span>
)}
</div>
{/* 信息 */}
<div className="as-card-info">
<p className="as-card-name" title={asset.name}>
{asset.name}
</p>
<div className="as-card-meta">{formatSize(asset.size)}</div>
</div>
</div>
)
})}
{filteredAssets.map((asset, idx) => (
<AssetCard
key={asset.id}
asset={asset}
isSelected={selectedSet.has(asset.id)}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
showCheckbox={showBatchSelect}
compact={compact}
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
onCardClick={(a, e) => handleCardClick(a, idx, e)}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
/>
))}
</div>
) : (
/* ── 列表视图 ── */
/* 列表视图 */
<div className="as-list">
{filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id)
const isDragging = dragIdx === idx
const isDragOver = dragOverIdx === idx
return (
<div
key={asset.id}
className={[
"as-list-item",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onClick={(e) => handleCardClick(asset, idx, e)}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
>
{/* 拖拽手柄 */}
<span className="as-list-item-drag" title="拖拽排序">
</span>
{/* Checkbox */}
{showBatchSelect && (
<span
data-checkbox
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
toggleSelect(asset, idx, e.shiftKey)
}}
/>
)}
{/* 图标 */}
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
{/* 信息 */}
<div className="as-list-item-info">
<div className="as-list-item-name">{asset.name}</div>
<div className="as-list-item-meta">
{MATERIAL_TYPE_LABELS[asset.type]}
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
{asset.size != null && ` · ${formatSize(asset.size)}`}
</div>
</div>
{/* 质量分 */}
{asset.quality_score != null && (
<span
className="as-list-item-quality"
style={{ color: getQualityColor(asset.quality_score) }}
>
{asset.quality_score}
</span>
)}
</div>
)
})}
{filteredAssets.map((asset, idx) => (
<AssetListItem
key={asset.id}
asset={asset}
isSelected={selectedSet.has(asset.id)}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
showCheckbox={showBatchSelect}
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
onCardClick={(a, e) => handleCardClick(a, idx, e)}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
/>
))}
</div>
)}
</div>
{/* ═══ 悬浮预览 ═══ */}
{previewAsset && (
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
<div className="as-preview-overlay-thumb">
{previewAsset.thumbnail_url ? (
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
) : (
<span className="as-preview-overlay-thumb-icon">
{MATERIAL_TYPE_ICONS[previewAsset.type]}
</span>
)}
</div>
<p className="as-preview-overlay-name">{previewAsset.name}</p>
<div className="as-preview-overlay-meta">
<span>: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
{previewAsset.duration != null && (
<span>: {formatDuration(previewAsset.duration)}</span>
)}
{previewAsset.size != null && <span>: {formatSize(previewAsset.size)}</span>}
{previewAsset.quality_score != null && (
<span>: {previewAsset.quality_score}</span>
)}
{previewAsset.tags.length > 0 && <span>: {previewAsset.tags.join(", ")}</span>}
</div>
</div>
)}
{/* 悬浮预览 */}
{previewAsset && <PreviewOverlay asset={previewAsset} position={previewPos} />}
</div>
)
}
@@ -0,0 +1,12 @@
import React from "react"
const EmptyState: React.FC = () => {
return (
<div className="as-empty">
<div className="as-empty-icon">📂</div>
<p></p>
</div>
)
}
export default EmptyState
@@ -0,0 +1,33 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration } from "./utils"
interface PreviewOverlayProps {
asset: MediaAsset
position: { x: number; y: number }
}
const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
return (
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
<div className="as-preview-overlay-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} />
) : (
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
</div>
<p className="as-preview-overlay-name">{asset.name}</p>
<div className="as-preview-overlay-meta">
<span>: {MATERIAL_TYPE_LABELS[asset.type]}</span>
{asset.duration != null && <span>: {formatDuration(asset.duration)}</span>}
{asset.size != null && <span>: {formatSize(asset.size)}</span>}
{asset.quality_score != null && <span>: {asset.quality_score}</span>}
{asset.tags.length > 0 && <span>: {asset.tags.join(", ")}</span>}
</div>
</div>
)
}
export default PreviewOverlay
@@ -0,0 +1,22 @@
import React from "react"
import { Button } from "@/components/ui"
interface SelectorBatchBarProps {
selectedCount: number
onClear: () => void
}
const SelectorBatchBar: React.FC<SelectorBatchBarProps> = ({ selectedCount, onClear }) => {
return (
<div className="as-batch-bar">
<span className="as-batch-bar-count"> {selectedCount} </span>
<div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={onClear}>
</Button>
</div>
</div>
)
}
export default SelectorBatchBar
@@ -0,0 +1,74 @@
import React from "react"
import { Input, Select } from "@/components/ui"
import { TYPE_OPTIONS } from "./constants"
import { QUALITY_OPTIONS } from "@/api/template-editor"
import type { ViewMode } from "./types"
interface SelectorToolbarProps {
searchText: string
filterType: string
filterQuality: string
viewMode: ViewMode
showQualityFilter: boolean
onSearchChange: (value: string) => void
onTypeChange: (value: string) => void
onQualityChange: (value: string) => void
onViewModeChange: (mode: ViewMode) => void
}
const SelectorToolbar: React.FC<SelectorToolbarProps> = ({
searchText,
filterType,
filterQuality,
viewMode,
showQualityFilter,
onSearchChange,
onTypeChange,
onQualityChange,
onViewModeChange,
}) => {
return (
<div className="as-toolbar">
<div className="as-toolbar-left">
<div className="as-search">
<Input
placeholder="搜索素材..."
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
prefix="🔍"
/>
</div>
<Select
value={filterType}
onChange={(v: string) => onTypeChange(v)}
options={TYPE_OPTIONS}
/>
{showQualityFilter && (
<Select
value={filterQuality}
onChange={(v: string) => onQualityChange(v)}
options={QUALITY_OPTIONS}
/>
)}
</div>
<div className="as-toolbar-right">
<button
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
onClick={() => onViewModeChange("grid")}
title="网格视图"
>
</button>
<button
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => onViewModeChange("list")}
title="列表视图"
>
</button>
</div>
</div>
)
}
export default SelectorToolbar
@@ -0,0 +1,7 @@
/** 类型筛选选项 */
export const TYPE_OPTIONS = [
{ value: "", label: "全部类型" },
{ value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" },
]
@@ -0,0 +1,63 @@
import { useState, useMemo } from "react"
import type { MediaAsset } from "@/api/template-editor"
import { QUALITY_OPTIONS } from "@/api/template-editor"
import type { ViewMode } from "../types"
interface UseAssetFilterReturn {
searchText: string
filterType: string
filterQuality: string
viewMode: ViewMode
filteredAssets: MediaAsset[]
setSearchText: (value: string) => void
setFilterType: (value: string) => void
setFilterQuality: (value: string) => void
setViewMode: (mode: ViewMode) => void
}
/**
* 素材筛选 Hook —— 搜索 + 类型 + 质量分 + 视图切换
*/
const useAssetFilter = (assets: MediaAsset[]): UseAssetFilterReturn => {
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState("")
const [filterQuality, setFilterQuality] = useState("")
const [viewMode, setViewMode] = useState<ViewMode>("grid")
const filteredAssets = useMemo(() => {
let list = assets
if (searchText) {
const q = searchText.toLowerCase()
list = list.filter(
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
)
}
if (filterType) {
list = list.filter((a) => a.type === filterType)
}
if (filterQuality) {
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
if (opt?.min != null && opt?.max != null) {
list = list.filter(
(a) =>
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
)
}
}
return list
}, [assets, searchText, filterType, filterQuality])
return {
searchText,
filterType,
filterQuality,
viewMode,
filteredAssets,
setSearchText,
setFilterType,
setFilterQuality,
setViewMode,
}
}
export default useAssetFilter
@@ -0,0 +1,54 @@
import { useState, useRef, useCallback, useEffect } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseAssetPreviewReturn {
previewAsset: MediaAsset | null
previewPos: { x: number; y: number }
handleMouseEnter: (asset: MediaAsset, e: React.MouseEvent) => void
handleMouseLeave: () => void
}
/**
* 悬浮预览 Hook —— 延迟 400ms 显示预览浮层
*/
const useAssetPreview = (): UseAssetPreviewReturn => {
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
if (previewTimer.current) clearTimeout(previewTimer.current)
previewTimer.current = setTimeout(() => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setPreviewAsset(asset)
setPreviewPos({
x: rect.right + 12,
y: Math.max(8, rect.top - 20),
})
}, 400)
}, [])
const handleMouseLeave = useCallback(() => {
if (previewTimer.current) {
clearTimeout(previewTimer.current)
previewTimer.current = null
}
setPreviewAsset(null)
}, [])
// 清理定时器
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current)
}
}, [])
return {
previewAsset,
previewPos,
handleMouseEnter,
handleMouseLeave,
}
}
export default useAssetPreview
@@ -0,0 +1,65 @@
import { useCallback, useRef, useMemo } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseAssetSelectionOptions {
filteredAssets: MediaAsset[]
selectedIds: string[]
onSelectionChange?: (ids: string[]) => void
}
interface UseAssetSelectionReturn {
selectedSet: Set<string>
toggleSelect: (asset: MediaAsset, idx: number, shiftKey: boolean) => void
clearSelection: () => void
}
/**
* 素材选择 Hook —— 单选/多选/Shift 连选
*/
const useAssetSelection = ({
filteredAssets,
selectedIds,
onSelectionChange,
}: UseAssetSelectionOptions): UseAssetSelectionReturn => {
const lastClickedIdx = useRef<number | null>(null)
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const toggleSelect = useCallback(
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
if (!onSelectionChange) return
if (shiftKey && lastClickedIdx.current !== null) {
// Shift 连选
const start = Math.min(lastClickedIdx.current, idx)
const end = Math.max(lastClickedIdx.current, idx)
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
const newSet = new Set(selectedIds)
rangeIds.forEach((id) => newSet.add(id))
onSelectionChange(Array.from(newSet))
} else {
const newSet = new Set(selectedIds)
if (newSet.has(asset.id)) {
newSet.delete(asset.id)
} else {
newSet.add(asset.id)
}
onSelectionChange(Array.from(newSet))
}
lastClickedIdx.current = idx
},
[onSelectionChange, selectedIds, filteredAssets],
)
const clearSelection = useCallback(() => {
onSelectionChange?.([])
}, [onSelectionChange])
return {
selectedSet,
toggleSelect,
clearSelection,
}
}
export default useAssetSelection
@@ -0,0 +1,89 @@
import { useState, useCallback } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseDragReorderOptions {
filteredAssets: MediaAsset[]
selectedIds: string[]
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
}
interface UseDragReorderReturn {
dragIdx: number | null
dragOverIdx: number | null
handleDragStart: (e: React.DragEvent, idx: number) => void
handleDragOver: (e: React.DragEvent, idx: number) => void
handleDrop: (e: React.DragEvent, toIdx: number) => void
handleDragEnd: () => void
}
/**
* 拖拽排序 Hook —— HTML5 DnD,支持批量拖拽携带数据
*/
const useDragReorder = ({
filteredAssets,
selectedIds,
onAssetDragStart,
onReorder,
}: UseDragReorderOptions): UseDragReorderReturn => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
setDragIdx(idx)
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", String(idx))
// 设置素材数据,供 TimelinePanel 接收
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
// 批量拖拽:如果有多个选中素材,一起携带
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
}
// 通知父组件素材拖拽开始
if (onAssetDragStart) {
onAssetDragStart(filteredAssets[idx])
}
},
[filteredAssets, selectedIds, onAssetDragStart],
)
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
if (dragIdx === null || dragIdx === idx) return
setDragOverIdx(idx)
},
[dragIdx],
)
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
onReorder(dragIdx, toIdx)
}
setDragIdx(null)
setDragOverIdx(null)
},
[dragIdx, onReorder],
)
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
return {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDrop,
handleDragEnd,
}
}
export default useDragReorder
@@ -1,2 +1,2 @@
export { default as AssetSelector } from "./AssetSelector"
export type { AssetSelectorProps } from "./AssetSelector"
export type { AssetSelectorProps, ViewMode } from "./types"
@@ -0,0 +1,14 @@
import type { MediaAsset } from "@/api/template-editor"
export interface AssetSelectorProps {
assets: MediaAsset[]
selectedIds?: string[]
onSelectionChange?: (ids: string[]) => void
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
showQualityFilter?: boolean
showBatchSelect?: boolean
compact?: boolean
}
export type ViewMode = "grid" | "list"
@@ -0,0 +1,33 @@
/** 格式化文件大小 */
export const formatSize = (bytes?: number): string => {
if (!bytes) return ""
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
/** 格式化时长 */
export const formatDuration = (seconds?: number): string => {
if (!seconds) return ""
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
}
/** 获取质量分等级 */
export const getQualityLevel = (score?: number): string => {
if (score == null) return "none"
if (score >= 90) return "excellent"
if (score >= 70) return "good"
if (score >= 50) return "fair"
return "poor"
}
/** 质量分颜色 */
export const getQualityColor = (score?: number): string => {
if (score == null) return "var(--text-secondary)"
if (score >= 90) return "var(--success-color, #10b981)"
if (score >= 70) return "var(--primary-color, #6366f1)"
if (score >= 50) return "var(--warning-color, #f59e0b)"
return "var(--error-color, #ef4444)"
}
@@ -11,10 +11,7 @@ export interface AssetUploadZoneProps {
onUpload: (file: File) => void
}
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
uploading,
onUpload,
}) => {
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
return (
<Upload.Dragger
beforeUpload={(file) => {
@@ -17,7 +17,11 @@ interface UseBatchDeleteOptions {
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({ selectedIds, invalidateAssets, showResult }: UseBatchDeleteOptions) => {
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
@@ -135,7 +139,11 @@ interface UseBatchClassifyOptions {
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({ selectedIds, queryClient, showResult }: UseBatchClassifyOptions) => {
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
@@ -73,10 +73,7 @@ interface UseBatchHelpersOptions {
* 批量操作辅助函数
* 刷新数据、显示操作结果
*/
export const useBatchHelpers = ({
queryClient,
setSelectedIds,
}: UseBatchHelpersOptions) => {
export const useBatchHelpers = ({ queryClient, setSelectedIds }: UseBatchHelpersOptions) => {
const invalidateAssets = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
@@ -78,7 +78,10 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
// 取任一批量操作的 loading 状态(任意一个在加载都算加载中)
const batchLoading =
deleteLoading || tagResult.batchLoading || classifyResult.batchLoading || markResult.batchLoading
deleteLoading ||
tagResult.batchLoading ||
classifyResult.batchLoading ||
markResult.batchLoading
/* ── 关闭结果 Drawer ── */
const handleResultDrawerClose = useCallback(() => {
@@ -1,238 +1,6 @@
/**
* 生成进度弹窗 — 任务 2.17
* 三阶段 UIsetup(配置)→ progress(进度轮询)→ completed / failed(结果)
* V21 设计系统,CSS 类名前缀 ep-gen-
* 生成进度弹窗 — 入口文件(向后兼容)
* 实际实现已移至 ./generation-progress-modal/ 目录
*/
import React, { useEffect, useRef } from "react"
import { Modal, Button } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
/* ──────────── 类型 ──────────── */
export type GenPhase = "setup" | "progress" | "completed" | "failed"
export interface GenerationProgressModalProps {
open: boolean
phase: GenPhase
/* setup 阶段 */
voiceoverDuration: number | null
estimatedDuration: number
onDurationChange: (v: number | null) => void
onGenerate: () => void
/* progress / 结果阶段 */
task: TaskItem | null
/* 通用 */
submitting: boolean
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
/* ──────────── 步骤文案映射 ──────────── */
const STEP_LABELS: Record<string, string> = {
queued: "排队中…",
preparing: "准备素材…",
generating_video: "渲染视频中…",
adding_effects: "添加特效…",
composing: "合成中…",
encoding: "编码输出中…",
completed: "生成完成!",
failed: "生成失败",
}
const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
/* ──────────── 状态徽标颜色 ──────────── */
const STATUS_COLOR: Record<string, string> = {
queued: "#6b7280",
pending: "#6b7280",
preparing: "#f59e0b",
generating_video: "#4f46e5",
adding_effects: "#7c3aed",
composing: "#2563eb",
encoding: "#0891b2",
completed: "#10b981",
failed: "#ef4444",
}
/* ──────────── 组件 ──────────── */
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
open,
phase,
voiceoverDuration,
estimatedDuration,
onDurationChange,
onGenerate,
task,
submitting,
onCancel,
onRetry,
onClose,
}) => {
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
const prevOpen = useRef(false)
useEffect(() => {
if (prevOpen.current && !open) {
/* modal just closed — parent handles reset */
}
prevOpen.current = open
}, [open])
const progress = task?.progress ?? 0
const status = task?.status ?? ""
const currentStep = task?.current_step ?? ""
const userMessage = task?.user_message ?? ""
const errorMessage = task?.error_message ?? ""
const retryable = task?.retryable ?? false
/* ── setup 阶段 ── */
if (phase === "setup") {
return (
<Modal
open={open}
title="使用模板生成视频"
confirmLoading={submitting}
onOk={onGenerate}
onCancel={onCancel}
okText="开始生成"
cancelText="取消"
width={440}
>
<div className="ep-gen-setup">
<label className="ep-gen-field-label"></label>
<input
type="number"
className="ep-gen-duration-input"
placeholder="请输入配音时长"
value={voiceoverDuration ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null
onDurationChange(v)
}}
min={1}
max={600}
/>
<div className="ep-gen-estimate">
<strong>{estimatedDuration}s</strong>
</div>
</div>
</Modal>
)
}
/* ── progress 阶段 ── */
if (phase === "progress") {
const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
return (
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
<div className="ep-gen-progress">
{/* 进度环 */}
<div className="ep-gen-progress-ring-wrap">
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
<circle
className="ep-gen-progress-ring-fill"
cx="60"
cy="60"
r="52"
style={{
strokeDasharray: `${2 * Math.PI * 52}`,
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
stroke: stepColor,
}}
/>
</svg>
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
{progress}%
</span>
</div>
{/* 当前步骤 */}
<div className="ep-gen-step-text">
{userMessage || getStepLabel(currentStep) || "处理中…"}
</div>
{/* 进度条 */}
<div className="ep-gen-progress-bar">
<div
className="ep-gen-progress-bar-fill"
style={{
width: `${progress}%`,
backgroundColor: stepColor,
}}
/>
</div>
{/* 任务 ID */}
{task?.id && <div className="ep-gen-task-id"> ID: {task.id}</div>}
</div>
</Modal>
)
}
/* ── completed 阶段 ── */
if (phase === "completed") {
return (
<Modal
open={open}
title="✅ 生成完成"
footer={null}
onCancel={onClose || onCancel}
closable
width={440}
>
<div className="ep-gen-result">
<div className="ep-gen-result-icon">🎉</div>
<div className="ep-gen-result-title"></div>
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
<div className="ep-gen-result-actions">
<Button buttonType="primary" onClick={onClose || onCancel}>
</Button>
</div>
</div>
</Modal>
)
}
/* ── failed 阶段 ── */
return (
<Modal
open={open}
title="❌ 生成失败"
footer={null}
onCancel={onClose || onCancel}
closable
width={440}
>
<div className="ep-gen-result ep-gen-result--error">
<div className="ep-gen-result-icon">😥</div>
<div className="ep-gen-result-title"></div>
{(errorMessage || userMessage) && (
<div className="ep-gen-result-msg ep-gen-result-msg--error">
{errorMessage || userMessage}
</div>
)}
<div className="ep-gen-result-actions">
{retryable && onRetry && (
<Button buttonType="primary" onClick={onRetry}>
🔄
</Button>
)}
<Button buttonType="secondary" onClick={onClose || onCancel}>
</Button>
</div>
</div>
</Modal>
)
}
export default GenerationProgressModal
export { default } from "./generation-progress-modal"
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
@@ -0,0 +1,65 @@
import React from "react"
import { Modal } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
import { getStepLabel, getStatusColor } from "./constants"
interface ProgressPhaseProps {
open: boolean
task: TaskItem | null
onCancel: () => void
}
/** progress(进度轮询)阶段弹窗 */
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
const progress = task?.progress ?? 0
const status = task?.status ?? ""
const currentStep = task?.current_step ?? ""
const userMessage = task?.user_message ?? ""
const stepColor = getStatusColor(status, currentStep)
return (
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
<div className="ep-gen-progress">
{/* 进度环 */}
<div className="ep-gen-progress-ring-wrap">
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
<circle
className="ep-gen-progress-ring-fill"
cx="60"
cy="60"
r="52"
style={{
strokeDasharray: `${2 * Math.PI * 52}`,
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
stroke: stepColor,
}}
/>
</svg>
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
{progress}%
</span>
</div>
{/* 当前步骤 */}
<div className="ep-gen-step-text">
{userMessage || getStepLabel(currentStep) || "处理中…"}
</div>
{/* 进度条 */}
<div className="ep-gen-progress-bar">
<div
className="ep-gen-progress-bar-fill"
style={{
width: `${progress}%`,
backgroundColor: stepColor,
}}
/>
</div>
{/* 任务 ID */}
{task?.id && <div className="ep-gen-task-id"> ID: {task.id}</div>}
</div>
</Modal>
)
}
@@ -0,0 +1,82 @@
import React from "react"
import { Modal, Button } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
interface ResultPhaseProps {
open: boolean
phase: "completed" | "failed"
task: TaskItem | null
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
/** completed / failed(结果)阶段弹窗 */
export const ResultPhase: React.FC<ResultPhaseProps> = ({
open,
phase,
task,
onCancel,
onRetry,
onClose,
}) => {
const userMessage = task?.user_message ?? ""
const errorMessage = task?.error_message ?? ""
const retryable = task?.retryable ?? false
const handleClose = onClose || onCancel
if (phase === "completed") {
return (
<Modal
open={open}
title="✅ 生成完成"
footer={null}
onCancel={handleClose}
closable
width={440}
>
<div className="ep-gen-result">
<div className="ep-gen-result-icon">🎉</div>
<div className="ep-gen-result-title"></div>
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
<div className="ep-gen-result-actions">
<Button buttonType="primary" onClick={handleClose}>
</Button>
</div>
</div>
</Modal>
)
}
return (
<Modal
open={open}
title="❌ 生成失败"
footer={null}
onCancel={handleClose}
closable
width={440}
>
<div className="ep-gen-result ep-gen-result--error">
<div className="ep-gen-result-icon">😥</div>
<div className="ep-gen-result-title"></div>
{(errorMessage || userMessage) && (
<div className="ep-gen-result-msg ep-gen-result-msg--error">
{errorMessage || userMessage}
</div>
)}
<div className="ep-gen-result-actions">
{retryable && onRetry && (
<Button buttonType="primary" onClick={onRetry}>
🔄
</Button>
)}
<Button buttonType="secondary" onClick={handleClose}>
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,53 @@
import React from "react"
import { Modal } from "@/components/ui"
interface SetupPhaseProps {
open: boolean
voiceoverDuration: number | null
estimatedDuration: number
submitting: boolean
onDurationChange: (v: number | null) => void
onGenerate: () => void
onCancel: () => void
}
/** setup(配置)阶段弹窗 */
export const SetupPhase: React.FC<SetupPhaseProps> = ({
open,
voiceoverDuration,
estimatedDuration,
submitting,
onDurationChange,
onGenerate,
onCancel,
}) => (
<Modal
open={open}
title="使用模板生成视频"
confirmLoading={submitting}
onOk={onGenerate}
onCancel={onCancel}
okText="开始生成"
cancelText="取消"
width={440}
>
<div className="ep-gen-setup">
<label className="ep-gen-field-label"></label>
<input
type="number"
className="ep-gen-duration-input"
placeholder="请输入配音时长"
value={voiceoverDuration ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null
onDurationChange(v)
}}
min={1}
max={600}
/>
<div className="ep-gen-estimate">
<strong>{estimatedDuration}s</strong>
</div>
</div>
</Modal>
)
@@ -0,0 +1,31 @@
/* ──────────── 步骤文案映射 ──────────── */
export const STEP_LABELS: Record<string, string> = {
queued: "排队中…",
preparing: "准备素材…",
generating_video: "渲染视频中…",
adding_effects: "添加特效…",
composing: "合成中…",
encoding: "编码输出中…",
completed: "生成完成!",
failed: "生成失败",
}
export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
/* ──────────── 状态徽标颜色 ──────────── */
export const STATUS_COLOR: Record<string, string> = {
queued: "#6b7280",
pending: "#6b7280",
preparing: "#f59e0b",
generating_video: "#4f46e5",
adding_effects: "#7c3aed",
composing: "#2563eb",
encoding: "#0891b2",
completed: "#10b981",
failed: "#ef4444",
}
export const getStatusColor = (status: string, currentStep: string) =>
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
@@ -0,0 +1,70 @@
/**
* 生成进度弹窗 — 任务 2.17
* 三阶段 UIsetup(配置)→ progress(进度轮询)→ completed / failed(结果)
* V21 设计系统,CSS 类名前缀 ep-gen-
*/
import React, { useEffect, useRef } from "react"
import type { GenPhase, GenerationProgressModalProps } from "./types"
import { SetupPhase } from "./SetupPhase"
import { ProgressPhase } from "./ProgressPhase"
import { ResultPhase } from "./ResultPhase"
/* 重新导出类型,保持向后兼容 */
export type { GenPhase, GenerationProgressModalProps }
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
open,
phase,
voiceoverDuration,
estimatedDuration,
onDurationChange,
onGenerate,
task,
submitting,
onCancel,
onRetry,
onClose,
}) => {
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
const prevOpen = useRef(false)
useEffect(() => {
if (prevOpen.current && !open) {
/* modal just closed — parent handles reset */
}
prevOpen.current = open
}, [open])
/* setup 阶段 */
if (phase === "setup") {
return (
<SetupPhase
open={open}
voiceoverDuration={voiceoverDuration}
estimatedDuration={estimatedDuration}
submitting={submitting}
onDurationChange={onDurationChange}
onGenerate={onGenerate}
onCancel={onCancel}
/>
)
}
/* progress 阶段 */
if (phase === "progress") {
return <ProgressPhase open={open} task={task} onCancel={onCancel} />
}
/* completed / failed 阶段 */
return (
<ResultPhase
open={open}
phase={phase as "completed" | "failed"}
task={task}
onCancel={onCancel}
onRetry={onRetry}
onClose={onClose}
/>
)
}
export default GenerationProgressModal
@@ -0,0 +1,23 @@
import type { TaskItem } from "@/api/tasks"
export type GenPhase = "setup" | "progress" | "completed" | "failed"
export interface GenerationProgressModalProps {
open: boolean
phase: GenPhase
/* setup 阶段 */
voiceoverDuration: number | null
estimatedDuration: number
onDurationChange: (v: number | null) => void
onGenerate: () => void
/* progress / 结果阶段 */
task: TaskItem | null
/* 通用 */
submitting: boolean
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
@@ -0,0 +1,64 @@
/**
* 错误提取工具
* 从各种响应格式中安全提取错误消息
*/
/** 安全提取字符串错误信息 */
export const safeExtractError = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (typeof obj.message === "object" && obj.message !== null)
return safeExtractError(obj.message)
return JSON.stringify(val)
}
return String(val ?? "")
}
/** 错误信息翻译/友好化 */
export const translateError = (msg: string): string => {
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
return "正在准备生成,请稍候再试"
}
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
return "所选模板或素材不可用,请重新选择"
}
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
return "素材数据异常,请返回视频库重新检查"
}
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
return "网络连接超时,请检查网络后重试"
}
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
return "已达到生成次数上限,请稍后再试或联系客服"
}
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
return "生成失败,请稍后重试或联系管理员"
}
/** 从 axios 错误对象中提取后端消息 */
export const extractBackendError = (err: unknown): string => {
const axiosErr = err as {
response?: {
data?: {
message?: string | object
error?: string | object
detail?: string | object
msg?: string | object
}
}
message?: string
}
return (
safeExtractError(axiosErr.response?.data?.message) ||
safeExtractError(axiosErr.response?.data?.error) ||
safeExtractError(axiosErr.response?.data?.detail) ||
safeExtractError(axiosErr.response?.data?.msg) ||
axiosErr.message ||
""
)
}
@@ -0,0 +1,9 @@
import type { GenerationPhase } from "./types"
/** 生成阶段映射 */
export const getGenerationPhase = (p: number): GenerationPhase => {
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
return { label: "即将完成", icon: "✨" }
}
@@ -0,0 +1,48 @@
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
import type { CoverConfig } from "../../../editing-planner/types"
import type { TitleSettings } from "../../types"
/** useGenerateVideo 入参 */
export interface UseGenerateVideoProps {
titleSettings: TitleSettings
selectedTemplate: string
selectedMaterials: string[]
materialMode: "manual" | "auto"
smartSelectedIds: string[]
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
coverSettings: CoverConfig
videoRatio: string
style: string
duration: number
autoSubtitles: boolean
bgm: boolean
generateCount: number
}
/** 生成阶段 */
export interface GenerationPhase {
label: string
icon: string
}
/** useGenerateVideo 返回值 */
export interface UseGenerateVideoResult {
generating: boolean
progress: number
generated: boolean
generateError: string | null
generatedVideos: GeneratedVideo[]
generate: () => Promise<void>
retry: () => void
dismissError: () => void
download: () => Promise<void>
share: () => Promise<void>
getGenerationPhase: (p: number) => GenerationPhase
}
export type EditPlanVoiceConfig = Pick<
EditPlanConfig,
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
>
@@ -0,0 +1,87 @@
import { useRef, useCallback } from "react"
import { message } from "antd"
import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor"
import { safeExtractError } from "./errorUtils"
interface UseGenerationPollingOptions {
templateId: string
onProgress: (progress: number) => void
onComplete: (videos: unknown[]) => void
onFailed: (errorMsg: string) => void
}
/**
* 生成状态轮询 Hook
* 轮询生成状态,更新进度,处理完成/失败
*/
export const useGenerationPolling = ({
templateId,
onProgress,
onComplete,
onFailed,
}: UseGenerationPollingOptions) => {
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
const clearTimer = useCallback(() => {
if (progressTimer.current) {
clearTimeout(progressTimer.current)
progressTimer.current = undefined
}
}, [])
const startPolling = useCallback(() => {
const poll = async () => {
try {
const data = await getGenerationStatus(templateId)
if (data.plan_status === "completed") {
onProgress(100)
// 获取生成的视频结果
let videos: unknown[] = []
if (data.generation_task_id) {
try {
videos = await getGenerationTaskResults(data.generation_task_id)
} catch (err) {
console.error("[获取生成结果失败]", err)
}
}
onComplete(videos)
message.success("视频生成完成!")
return
}
if (data.plan_status === "failed") {
const dataAny = data as unknown as Record<string, unknown>
const rawMsg =
dataAny.error_message ||
dataAny.error ||
dataAny.message ||
(Array.isArray(data.clips)
? (data.clips as { status: string; error_message?: string }[]).find(
(c) => c.status === "failed",
)?.error_message
: undefined) ||
"视频生成失败,请联系管理员或重试"
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] templateId:", templateId, "响应:", data)
onFailed(errorMsg)
message.error(errorMsg)
return
}
const clips = data.clips || []
const total = clips.length || 1
const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length
onProgress(Math.round((done / total) * 100))
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
console.error("[轮询出错] templateId:", templateId, pollErr)
progressTimer.current = setTimeout(poll, 3000)
}
}
progressTimer.current = setTimeout(poll, 2000)
}, [templateId, onProgress, onComplete, onFailed])
return { startPolling, clearTimer }
}
@@ -0,0 +1,33 @@
import type { EditPlanConfig } from "@/api/template-editor"
type VoiceConfig = Pick<
EditPlanConfig,
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
>
interface BuildVoiceConfigOptions {
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
}
/**
* 根据 voiceMode 构建配音配置
*/
export const buildVoiceConfig = ({
voiceMode,
selectedVoice,
selectedClonedVoice,
}: BuildVoiceConfigOptions): VoiceConfig => {
const voiceConfig: VoiceConfig = {}
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined
} else if (voiceMode === "clone") {
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
} else if (voiceMode === "custom") {
voiceConfig.voice_id = selectedVoice || undefined
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
// 自定义配音模式需从 step5 组件传回
}
return voiceConfig
}
@@ -2,54 +2,35 @@
* 视频生成 Hook
* 封装视频生成的核心逻辑、状态管理、轮询等
*/
import { useState, useRef, useCallback } from "react"
import { useState, useCallback } from "react"
import { message } from "antd"
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
import {
generateEditPlan,
updateEditPlan,
getGenerationTaskResults,
getGenerationStatus,
getEditPlan,
} from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { TitleSettings } from "../types"
import type { GeneratedVideo } from "@/api/template-editor"
import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
import { buildVoiceConfig } from "./generate-video/voiceConfig"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
interface UseGenerateVideoProps {
titleSettings: TitleSettings
selectedTemplate: string
selectedMaterials: string[]
materialMode: "manual" | "auto"
smartSelectedIds: string[]
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
coverSettings: CoverConfig
videoRatio: string
style: string
duration: number
autoSubtitles: boolean
bgm: boolean
generateCount: number
}
export function useGenerateVideo(props: UseGenerateVideoProps) {
const {
titleSettings,
selectedTemplate,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
} = props
export function useGenerateVideo({
titleSettings,
selectedTemplate,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
}: UseGenerateVideoProps) {
/* ── 生成状态 ── */
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
@@ -57,24 +38,26 @@ export function useGenerateVideo({
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
const handleProgress = useCallback((p: number) => setProgress(p), [])
const handleComplete = useCallback((videos: unknown[]) => {
setGenerating(false)
setGenerated(true)
setGeneratedVideos(videos as GeneratedVideo[])
}, [])
const handleFailed = useCallback((errorMsg: string) => {
setGenerating(false)
setGenerateError(errorMsg)
}, [])
/* ── 生成阶段映射 ── */
const getGenerationPhase = (p: number) => {
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
return { label: "即将完成", icon: "✨" }
}
const { startPolling, clearTimer } = useGenerationPolling({
templateId: selectedTemplate,
onProgress: handleProgress,
onComplete: handleComplete,
onFailed: handleFailed,
})
/* ── 生成视频 ── */
const generate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
titleSettings,
selectedTemplate,
selectedMaterials,
voiceMode,
})
if (!titleSettings.title.trim()) {
message.warning("请先选择或输入标题")
return
@@ -83,7 +66,6 @@ export function useGenerateVideo({
message.warning("请至少选择一个素材")
return
}
if (voiceMode === "clone" && !selectedClonedVoice) {
message.warning("请先选择一个克隆音色")
return
@@ -93,21 +75,14 @@ export function useGenerateVideo({
setProgress(0)
setGenerated(false)
setGenerateError(null)
clearTimer()
try {
const voiceConfig: Pick<
EditPlanConfig,
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
> = {}
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined
} else if (voiceMode === "clone") {
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
} else if (voiceMode === "custom") {
voiceConfig.voice_id = selectedVoice || undefined
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
// 自定义配音模式需从 step5 组件传回
}
const voiceConfig = buildVoiceConfig({
voiceMode,
selectedVoice,
selectedClonedVoice,
})
// 获取或创建草稿
await getEditPlan(selectedTemplate)
@@ -140,149 +115,13 @@ export function useGenerateVideo({
})
await generateEditPlan(selectedTemplate)
const poll = async () => {
try {
const data = await getGenerationStatus(selectedTemplate)
if (data.plan_status === "completed") {
setProgress(100)
setGenerating(false)
setGenerated(true)
// 获取生成的视频结果
if (data.generation_task_id) {
try {
const videos = await getGenerationTaskResults(data.generation_task_id)
setGeneratedVideos(videos)
} catch (err) {
console.error("[获取生成结果失败]", err)
}
}
message.success("视频生成完成!")
return
}
if (data.plan_status === "failed") {
setGenerating(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
const dataAny = data as Record<string, any>
const rawMsg =
dataAny.error_message ||
dataAny.error ||
dataAny.message ||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
?.error_message ||
"视频生成失败,请联系管理员或重试"
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
return JSON.stringify(val)
}
return String(val ?? "")
}
const errorMsg = safeExtract(rawMsg)
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
setGenerateError(errorMsg)
message.error(errorMsg)
return
}
const clips = data.clips || []
const total = clips.length || 1
const done = clips.filter((c: { status: string }) => c.status === "completed").length
setProgress(Math.round((done / total) * 100))
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
typeof setInterval
>
} catch (pollErr) {
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
typeof setInterval
>
}
}
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
startPolling()
} catch (err: unknown) {
console.error("[handleGenerate] 生成失败:", err)
setGenerating(false)
const axiosErr = err as {
response?: {
data?: {
message?: string | object
error?: string | object
detail?: string | object
msg?: string | object
}
}
message?: string
}
const extractString = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (typeof obj.message === "object" && obj.message !== null)
return extractString(obj.message)
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
return JSON.stringify(val)
}
return ""
}
const backendMsg =
extractString(axiosErr.response?.data?.message) ||
extractString(axiosErr.response?.data?.error) ||
extractString(axiosErr.response?.data?.detail) ||
extractString(axiosErr.response?.data?.msg) ||
axiosErr.message ||
""
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (typeof obj.message === "object") return safeExtractErr(obj.message)
return JSON.stringify(val)
}
return String(val ?? "")
}
const rawError = safeExtractErr(backendMsg)
const translateError = (msg: string): string => {
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
return "正在准备生成,请稍候再试"
}
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
return "所选模板或素材不可用,请重新选择"
}
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
return "素材数据异常,请返回视频库重新检查"
}
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
return "网络连接超时,请检查网络后重试"
}
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
return "已达到生成次数上限,请稍后再试或联系客服"
}
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
return "生成失败,请稍后重试或联系管理员"
}
const finalMsg = translateError(rawError)
const backendMsg = extractBackendError(err)
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err)
const finalMsg = translateError(backendMsg)
setGenerateError(finalMsg)
message.error(finalMsg)
}
@@ -302,6 +141,8 @@ export function useGenerateVideo({
materialMode,
coverSettings,
smartSelectedIds,
clearTimer,
startPolling,
])
/* 重新生成(失败后重试) */
@@ -59,7 +59,7 @@ export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditPro
message.success("标题创建成功")
},
})
}, [newTitleContent, newTitleType, createMutation])
}, [newTitleContent, createMutation])
/* 关闭新建弹窗 */
const handleCloseCreateModal = useCallback(() => {
@@ -9,21 +9,8 @@
* - 删除素材
*/
import React from "react"
import {
AudioOutlined,
SearchOutlined,
PlusOutlined,
DeleteOutlined,
UploadOutlined,
UnorderedListOutlined,
AppstoreOutlined,
CheckOutlined,
TagsOutlined,
RobotOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { Button, Input, Select, Modal } from "@/components/ui"
import { Popover, Popconfirm } from "antd"
import { PlusOutlined, RobotOutlined } from "@ant-design/icons"
import { Button, Modal } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
import { useAudioPlayer } from "./hooks/useAudioPlayer"
@@ -32,6 +19,11 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import MaterialForm from "./components/MaterialForm"
import VoiceMaterialCard from "./components/VoiceMaterialCard"
import VoiceMaterialRow from "./components/VoiceMaterialRow"
import Toolbar from "./components/Toolbar"
import TagFilterBar from "./components/TagFilterBar"
import BatchBar from "./components/BatchBar"
import EmptyState from "./components/EmptyState"
import TtsModal from "./components/TtsModal"
import "./voice-materials.css"
/* ============================================================
@@ -137,7 +129,7 @@ const VoiceMaterialLibrary: React.FC = () => {
})
}
/* ── 渲染 ─────────────────────────────────────────────── */
const hasFilter = !!searchText || filterGender !== "all" || filterTagId !== "all"
const pageActions = (
<div className="vmat-page-actions">
@@ -164,155 +156,47 @@ const VoiceMaterialLibrary: React.FC = () => {
/>
{/* 工具栏:搜索 + 筛选 + 视图切换 */}
<div className="vmat-toolbar">
<div className="vmat-toolbar-left">
<Input
placeholder="搜索配音素材..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 240 }}
/>
<Select
value={filterGender}
onChange={setFilterGender}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部性别" },
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "neutral", label: "中性" },
]}
/>
</div>
<div className="vmat-toolbar-right">
<span className="vmat-result-count"> {filtered.length} </span>
<div className="vmat-view-toggle">
<button
type="button"
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
onClick={() => setViewMode("card")}
title="卡片视图"
>
<AppstoreOutlined />
</button>
<button
type="button"
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => setViewMode("list")}
title="列表视图"
>
<UnorderedListOutlined />
</button>
</div>
</div>
</div>
<Toolbar
searchText={searchText}
filterGender={filterGender}
viewMode={viewMode}
resultCount={filtered.length}
onSearchChange={setSearchText}
onGenderChange={setFilterGender}
onViewModeChange={setViewMode}
/>
{/* ── 标签筛选药丸条 ─────────────────────────────────── */}
{tags.length > 0 && (
<div className="vmat-tag-filter-bar">
<button
type="button"
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
onClick={() => setFilterTagId("all")}
tabIndex={0}
>
</button>
{tags.map((tag) => (
<button
key={tag.id}
type="button"
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
onClick={() => setFilterTagId(tag.id)}
tabIndex={0}
>
{tag.name}
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
</button>
))}
</div>
)}
{/* 加载中 */}
{isLoading && (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3>...</h3>
</div>
)}
{/* 标签筛选药丸条 */}
<TagFilterBar
tags={tags}
filterTagId={filterTagId}
tagCountMap={tagCountMap}
onTagSelect={setFilterTagId}
/>
{/* 批量操作栏 */}
{batchMode && (
<div className="vmat-batch-bar">
<div className="vmat-batch-bar-left">
<div
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
onClick={handleSelectAll}
>
{allSelected && <CheckOutlined />}
</div>
<span className="vmat-select-all" onClick={handleSelectAll}>
{allSelected ? "取消全选" : "全选"}
</span>
<span className="vmat-batch-count"> {selectedIds.size} </span>
</div>
<div className="vmat-batch-bar-right">
<Popover
content={
<div className="vmat-tag-popover">
<div className="vmat-tag-pop-input-row">
<Input
placeholder="输入自定义标签后回车"
value={batchCustomTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setBatchCustomTag(e.target.value)
}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && batchCustomTag.trim()) {
handleBatchCustomTag(batchCustomTag.trim())
setBatchCustomTag("")
}
}}
/>
</div>
{tags.map((tag) => (
<button
key={tag.id}
type="button"
className="vmat-tag-pop-btn"
onClick={() => handleBatchTag(tag.id)}
>
{tag.name}
</button>
))}
</div>
}
title="批量打标签"
trigger="click"
>
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
</Button>
</Popover>
<Popconfirm
title={`确定删除选中的 ${selectedIds.size} 个素材?`}
onConfirm={handleBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
</div>
<BatchBar
selectedCount={selectedIds.size}
allSelected={allSelected}
batchCustomTag={batchCustomTag}
tags={tags}
onSelectAll={handleSelectAll}
onBatchCustomTagChange={setBatchCustomTag}
onBatchCustomTagSubmit={handleBatchCustomTag}
onBatchTag={handleBatchTag}
onBatchDelete={handleBatchDelete}
/>
)}
{/* 加载/空状态 */}
<EmptyState
isLoading={isLoading}
isEmpty={filtered.length === 0}
hasFilter={hasFilter}
onUploadClick={() => setUploadOpen(true)}
/>
{/* 内容区 — 卡片视图 */}
{!isLoading && filtered.length > 0 && viewMode === "card" && (
<div className="vmat-grid">
@@ -374,31 +258,6 @@ const VoiceMaterialLibrary: React.FC = () => {
</div>
)}
{/* 空状态 */}
{!isLoading && filtered.length === 0 && (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3></h3>
<p>
{searchText || filterGender !== "all" || filterTagId !== "all"
? "未找到匹配的素材,试试调整筛选条件"
: "上传音频文件,开始管理配音素材"}
</p>
{!searchText && filterGender === "all" && filterTagId === "all" && (
<Button
buttonType="primary"
buttonSize="md"
icon={<UploadOutlined />}
onClick={() => setUploadOpen(true)}
>
</Button>
)}
</div>
)}
{/* 上传弹窗 */}
<Modal
title="上传配音素材"
@@ -450,151 +309,22 @@ const VoiceMaterialLibrary: React.FC = () => {
width={560}
destroyOnClose
>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* 文本输入 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
</label>
<textarea
rows={4}
placeholder="请输入需要转换为语音的文本内容…"
value={ttsText}
onChange={(e) => setTtsText(e.target.value)}
maxLength={2000}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary, #999)",
marginTop: 4,
textAlign: "right",
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
</label>
<select
value={ttsVoiceId}
onChange={(e) => setTtsVoiceId(e.target.value)}
style={{
width: "100%",
height: 36,
padding: "0 10px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
background: "var(--bg-primary, #fff)",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name}
</option>
))}
</select>
</div>
{/* 语速调节 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
{ttsSpeed.toFixed(1)}x
</label>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
style={{ width: "100%" }}
/>
</div>
{/* 合成按钮 */}
<Button
buttonType="primary"
buttonSize="md"
icon={ttsStatus === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
onClick={handleTtsSynthesize}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
>
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
</Button>
{/* 错误提示 */}
{ttsStatus === "error" && ttsError && (
<div
style={{
padding: "8px 12px",
background: "#fff2f0",
borderRadius: 6,
color: "#ff4d4f",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-surface, #f5f5f5)",
borderRadius: 8,
}}
>
<audio controls src={ttsAudioUrl} style={{ width: "100%", marginBottom: 12 }} />
<Button
buttonType="primary"
buttonSize="sm"
icon={<PlusOutlined />}
onClick={handleTtsSave}
>
</Button>
</div>
)}
</div>
<TtsModal
open={ttsOpen}
text={ttsText}
voiceId={ttsVoiceId}
speed={ttsSpeed}
status={ttsStatus}
audioUrl={ttsAudioUrl ?? ""}
error={ttsError ?? ""}
presetVoices={presetVoices}
onClose={handleTtsClose}
onTextChange={setTtsText}
onVoiceChange={setTtsVoiceId}
onSpeedChange={setTtsSpeed}
onSynthesize={handleTtsSynthesize}
onSave={handleTtsSave}
/>
</Modal>
</div>
)
@@ -0,0 +1,94 @@
import React from "react"
import { CheckOutlined, TagsOutlined, DeleteOutlined } from "@ant-design/icons"
import { Button, Input } from "@/components/ui"
import { Popover, Popconfirm } from "antd"
import type { TagItem } from "@/api/tags"
interface BatchBarProps {
selectedCount: number
allSelected: boolean
batchCustomTag: string
tags: TagItem[]
onSelectAll: () => void
onBatchCustomTagChange: (value: string) => void
onBatchCustomTagSubmit: (tag: string) => void
onBatchTag: (tagId: string) => void
onBatchDelete: () => void
}
const BatchBar: React.FC<BatchBarProps> = ({
selectedCount,
allSelected,
batchCustomTag,
tags,
onSelectAll,
onBatchCustomTagChange,
onBatchCustomTagSubmit,
onBatchTag,
onBatchDelete,
}) => {
return (
<div className="vmat-batch-bar">
<div className="vmat-batch-bar-left">
<div className={`vmat-checkbox${allSelected ? " checked" : ""}`} onClick={onSelectAll}>
{allSelected && <CheckOutlined />}
</div>
<span className="vmat-select-all" onClick={onSelectAll}>
{allSelected ? "取消全选" : "全选"}
</span>
<span className="vmat-batch-count"> {selectedCount} </span>
</div>
<div className="vmat-batch-bar-right">
<Popover
content={
<div className="vmat-tag-popover">
<div className="vmat-tag-pop-input-row">
<Input
placeholder="输入自定义标签后回车"
value={batchCustomTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onBatchCustomTagChange(e.target.value)
}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && batchCustomTag.trim()) {
onBatchCustomTagSubmit(batchCustomTag.trim())
onBatchCustomTagChange("")
}
}}
/>
</div>
{tags.map((tag) => (
<button
key={tag.id}
type="button"
className="vmat-tag-pop-btn"
onClick={() => onBatchTag(tag.id)}
>
{tag.name}
</button>
))}
</div>
}
title="批量打标签"
trigger="click"
>
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
</Button>
</Popover>
<Popconfirm
title={`确定删除选中的 ${selectedCount} 个素材?`}
onConfirm={onBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
</div>
)
}
export default BatchBar
@@ -0,0 +1,52 @@
import React from "react"
import { AudioOutlined, UploadOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
interface EmptyStateProps {
isLoading: boolean
isEmpty: boolean
hasFilter: boolean
onUploadClick: () => void
}
const EmptyState: React.FC<EmptyStateProps> = ({
isLoading,
isEmpty,
hasFilter,
onUploadClick,
}) => {
if (isLoading) {
return (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3>...</h3>
</div>
)
}
if (!isEmpty) return null
return (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3></h3>
<p>{hasFilter ? "未找到匹配的素材,试试调整筛选条件" : "上传音频文件,开始管理配音素材"}</p>
{!hasFilter && (
<Button
buttonType="primary"
buttonSize="md"
icon={<UploadOutlined />}
onClick={onUploadClick}
>
</Button>
)}
</div>
)
}
export default EmptyState
@@ -0,0 +1,45 @@
import React from "react"
import type { TagItem } from "@/api/tags"
interface TagFilterBarProps {
tags: TagItem[]
filterTagId: string
tagCountMap: Record<string, number>
onTagSelect: (tagId: string) => void
}
const TagFilterBar: React.FC<TagFilterBarProps> = ({
tags,
filterTagId,
tagCountMap,
onTagSelect,
}) => {
if (tags.length === 0) return null
return (
<div className="vmat-tag-filter-bar">
<button
type="button"
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
onClick={() => onTagSelect("all")}
tabIndex={0}
>
</button>
{tags.map((tag) => (
<button
key={tag.id}
type="button"
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
onClick={() => onTagSelect(tag.id)}
tabIndex={0}
>
{tag.name}
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
</button>
))}
</div>
)
}
export default TagFilterBar
@@ -0,0 +1,76 @@
import React from "react"
import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined } from "@ant-design/icons"
import { Input, Select } from "@/components/ui"
import type { ViewMode } from "../types"
interface ToolbarProps {
searchText: string
filterGender: string
viewMode: ViewMode
resultCount: number
onSearchChange: (value: string) => void
onGenderChange: (value: string) => void
onViewModeChange: (mode: ViewMode) => void
}
const GENDER_OPTIONS = [
{ value: "all", label: "全部性别" },
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "neutral", label: "中性" },
]
const Toolbar: React.FC<ToolbarProps> = ({
searchText,
filterGender,
viewMode,
resultCount,
onSearchChange,
onGenderChange,
onViewModeChange,
}) => {
return (
<div className="vmat-toolbar">
<div className="vmat-toolbar-left">
<Input
placeholder="搜索配音素材..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 240 }}
/>
<Select
value={filterGender}
onChange={onGenderChange}
style={{ width: 120 }}
options={GENDER_OPTIONS}
/>
</div>
<div className="vmat-toolbar-right">
<span className="vmat-result-count"> {resultCount} </span>
<div className="vmat-view-toggle">
<button
type="button"
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
onClick={() => onViewModeChange("card")}
title="卡片视图"
>
<AppstoreOutlined />
</button>
<button
type="button"
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => onViewModeChange("list")}
title="列表视图"
>
<UnorderedListOutlined />
</button>
</div>
</div>
</div>
)
}
export default Toolbar
+171
View File
@@ -0,0 +1,171 @@
import React from "react"
import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsPresetVoice {
voice_id: string
name: string
}
interface TtsModalProps {
open: boolean
text: string
voiceId: string
speed: number
status: TtsStatus
audioUrl: string
error: string
presetVoices: TtsPresetVoice[]
onClose: () => void
onTextChange: (value: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
const TtsModal: React.FC<TtsModalProps> = ({
open,
text,
voiceId,
speed,
status,
audioUrl,
error,
presetVoices,
onClose: _onClose,
onTextChange,
onVoiceChange,
onSpeedChange,
onSynthesize,
onSave,
}) => {
if (!open) return null
const labelStyle: React.CSSProperties = {
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* 文本输入 */}
<div>
<label style={labelStyle}></label>
<textarea
rows={4}
placeholder="请输入需要转换为语音的文本内容…"
value={text}
onChange={(e) => onTextChange(e.target.value)}
maxLength={2000}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary, #999)",
marginTop: 4,
textAlign: "right",
}}
>
{text.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<label style={labelStyle}></label>
<select
value={voiceId}
onChange={(e) => onVoiceChange(e.target.value)}
style={{
width: "100%",
height: 36,
padding: "0 10px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
background: "var(--bg-primary, #fff)",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name}
</option>
))}
</select>
</div>
{/* 语速调节 */}
<div>
<label style={labelStyle}>{speed.toFixed(1)}x</label>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={speed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{ width: "100%" }}
/>
</div>
{/* 合成按钮 */}
<Button
buttonType="primary"
buttonSize="md"
icon={status === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
onClick={onSynthesize}
disabled={status === "synthesizing" || !text.trim()}
>
{status === "synthesizing" ? "合成中…" : "开始合成"}
</Button>
{/* 错误提示 */}
{status === "error" && error && (
<div
style={{
padding: "8px 12px",
background: "#fff2f0",
borderRadius: 6,
color: "#ff4d4f",
fontSize: 13,
}}
>
{error}
</div>
)}
{/* 合成结果 */}
{status === "done" && audioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-surface, #f5f5f5)",
borderRadius: 8,
}}
>
<audio controls src={audioUrl} style={{ width: "100%", marginBottom: 12 }} />
<Button buttonType="primary" buttonSize="sm" icon={<PlusOutlined />} onClick={onSave}>
</Button>
</div>
)}
</div>
)
}
export default TtsModal
@@ -213,4 +213,4 @@ export function useVoiceMaterialActions({
handleEdit,
handleDelete,
}
}
}
+16 -221
View File
@@ -1,27 +1,12 @@
import React from "react"
import { RobotOutlined } from "@ant-design/icons"
import { Modal, message } from "antd"
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import { genderLabel } from "@/pages/voices/utils/format"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsModalProps {
open: boolean
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
onClose: () => void
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
import { Modal } from "antd"
import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
import TextInputSection from "./tts-modal/TextInputSection"
import VoiceSelector from "./tts-modal/VoiceSelector"
import SpeedControl from "./tts-modal/SpeedControl"
import SynthesizeButton from "./tts-modal/SynthesizeButton"
import ErrorAlert from "./tts-modal/ErrorAlert"
import ResultPanel from "./tts-modal/ResultPanel"
/** AI 配音弹窗 */
const TtsModal: React.FC<TtsModalProps> = ({
@@ -50,205 +35,13 @@ const TtsModal: React.FC<TtsModalProps> = ({
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={ttsText}
onChange={(e) => onTextChange(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={ttsVoiceId}
onChange={(e) => onVoiceChange(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={() => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本")
return
}
onSynthesize()
}}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
<TextInputSection value={ttsText} onChange={onTextChange} />
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
{ttsError && <ErrorAlert error={ttsError} />}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={onSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
<ResultPanel audioUrl={ttsAudioUrl} onSave={onSave} />
)}
</div>
</Modal>
@@ -256,3 +49,5 @@ const TtsModal: React.FC<TtsModalProps> = ({
}
export default TtsModal
export type { TtsModalProps, TtsStatus }
@@ -1,24 +1,13 @@
import React from "react"
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
import { Modal, Upload, message } from "antd"
import { type VoiceGender } from "@/pages/voices/types"
import { formatFileSize } from "@/pages/voices/utils/format"
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
}
import { Modal } from "antd"
import {
FileUploadZone,
FileInfoCard,
UploadProgress,
FormFields,
ActionButtons,
} from "./upload-voice-modal"
import type { UploadVoiceModalProps } from "./upload-voice-modal"
/** 上传音频弹窗 */
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
@@ -36,17 +25,20 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
onDescChange,
onUpload,
}) => {
const uploading = uploadProgress !== null
const canUpload = !!uploadFile && !!uploadName.trim()
return (
<Modal
title="上传音频"
open={open}
onCancel={() => {
if (uploadProgress !== null) return // 上传中不可关闭
if (uploading) return // 上传中不可关闭
onClose()
}}
footer={null}
width={520}
maskClosable={uploadProgress === null}
maskClosable={!uploading}
>
<div
style={{
@@ -57,270 +49,36 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
}}
>
{/* 拖拽上传区 */}
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
onFileSelect(file)
return false
}}
onRemove={() => {
onFileRemove()
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
<FileUploadZone
disabled={uploading}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
/>
{/* 已选文件信息 */}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{uploadFile && <FileInfoCard file={uploadFile} />}
{/* 上传进度 */}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{uploadProgress !== null && <UploadProgress progress={uploadProgress} />}
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<input
value={uploadName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onGenderChange(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={uploadDesc}
onChange={(e) => onDescChange(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 表单字段 */}
<FormFields
name={uploadName}
gender={uploadGender}
desc={uploadDesc}
disabled={uploading}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
onDescChange={onDescChange}
/>
{/* 操作按钮 */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={onClose}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件")
return
}
if (!uploadName.trim()) {
message.warning("请输入素材名称")
return
}
onUpload()
}}
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
<ActionButtons
uploading={uploading}
canUpload={canUpload}
onCancel={onClose}
onUpload={onUpload}
/>
</div>
</Modal>
)
@@ -0,0 +1,24 @@
import React from "react"
interface ErrorAlertProps {
error: string
}
/** 错误提示 */
const ErrorAlert: React.FC<ErrorAlertProps> = ({ error }) => {
return (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{error}
</div>
)
}
export default ErrorAlert
@@ -0,0 +1,51 @@
import React from "react"
interface ResultPanelProps {
audioUrl: string
onSave: () => void
}
/** 合成结果展示 */
const ResultPanel: React.FC<ResultPanelProps> = ({ audioUrl, onSave }) => {
return (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={audioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={onSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
)
}
export default ResultPanel
@@ -0,0 +1,47 @@
import React from "react"
import { TTS_CONFIG } from "./types"
interface SpeedControlProps {
speed: number
onChange: (speed: number) => void
}
/** 语速调节滑块 */
const SpeedControl: React.FC<SpeedControlProps> = ({ speed, onChange }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{speed.toFixed(1)}x
</div>
<input
type="range"
min={TTS_CONFIG.MIN_SPEED}
max={TTS_CONFIG.MAX_SPEED}
step={TTS_CONFIG.SPEED_STEP}
value={speed}
onChange={(e) => onChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>{TTS_CONFIG.MIN_SPEED}x</span>
<span>{TTS_CONFIG.DEFAULT_SPEED}x</span>
<span>{TTS_CONFIG.MAX_SPEED}x</span>
</div>
</div>
)
}
export default SpeedControl
@@ -0,0 +1,51 @@
import React from "react"
import { RobotOutlined } from "@ant-design/icons"
import { message } from "antd"
import { type TtsStatus } from "./types"
interface SynthesizeButtonProps {
status: TtsStatus
text: string
onClick: () => void
}
/** 合成按钮 */
const SynthesizeButton: React.FC<SynthesizeButtonProps> = ({ status, text, onClick }) => {
const disabled = status === "synthesizing" || !text.trim()
const handleClick = () => {
if (!text.trim()) {
message.warning("请输入要合成的文本")
return
}
onClick()
}
return (
<button
type="button"
onClick={handleClick}
disabled={disabled}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: disabled ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{status === "synthesizing" ? "合成中..." : "开始合成"}
</button>
)
}
export default SynthesizeButton
@@ -0,0 +1,56 @@
import React from "react"
import { TTS_CONFIG } from "./types"
interface TextInputSectionProps {
value: string
onChange: (text: string) => void
}
/** 文本输入区 */
const TextInputSection: React.FC<TextInputSectionProps> = ({ value, onChange }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={TTS_CONFIG.MAX_TEXT_LENGTH}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{value.length}/{TTS_CONFIG.MAX_TEXT_LENGTH}
</div>
</div>
)
}
export default TextInputSection
@@ -0,0 +1,49 @@
import React from "react"
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import { genderLabel } from "@/pages/voices/utils/format"
interface VoiceSelectorProps {
value: string
onChange: (voiceId: string) => void
presetVoices: PresetVoiceDisplay[]
}
/** 音色选择下拉 */
const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVoices }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
)
}
export default VoiceSelector
+8
View File
@@ -0,0 +1,8 @@
export { default } from "../TtsModal"
export * from "./types"
export { default as TextInputSection } from "./TextInputSection"
export { default as VoiceSelector } from "./VoiceSelector"
export { default as SpeedControl } from "./SpeedControl"
export { default as SynthesizeButton } from "./SynthesizeButton"
export { default as ErrorAlert } from "./ErrorAlert"
export { default as ResultPanel } from "./ResultPanel"
+29
View File
@@ -0,0 +1,29 @@
import { type PresetVoiceDisplay } from "@/pages/voices/types"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsModalProps {
open: boolean
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
onClose: () => void
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
/** TTS 常量配置 */
export const TTS_CONFIG = {
MAX_TEXT_LENGTH: 2000,
DEFAULT_SPEED: 1.0,
MIN_SPEED: 0.5,
MAX_SPEED: 2.0,
SPEED_STEP: 0.1,
} as const
@@ -0,0 +1,68 @@
import React from "react"
interface ActionButtonsProps {
uploading: boolean
canUpload: boolean
onCancel: () => void
onUpload: () => void
}
const ActionButtons: React.FC<ActionButtonsProps> = ({
uploading,
canUpload,
onCancel,
onUpload,
}) => {
const disabled = uploading || !canUpload
const handleUpload = () => {
onUpload()
}
return (
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={onCancel}
disabled={uploading}
style={{
padding: "8px 20px",
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}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor: disabled ? "not-allowed" : "pointer",
}}
>
{uploading ? "上传中..." : "开始上传"}
</button>
</div>
)
}
export default ActionButtons
@@ -0,0 +1,42 @@
import React from "react"
import { SoundOutlined } from "@ant-design/icons"
import { formatFileSize } from "@/pages/voices/utils/format"
interface FileInfoCardProps {
file: File
}
const FileInfoCard: React.FC<FileInfoCardProps> = ({ file }) => {
return (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{file.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(file.size)}
</div>
</div>
</div>
)
}
export default FileInfoCard
@@ -0,0 +1,54 @@
import React from "react"
import { UploadOutlined } from "@ant-design/icons"
import { Upload } from "antd"
import { UPLOAD_CONFIG } from "./types"
interface FileUploadZoneProps {
disabled: boolean
onFileSelect: (file: File) => void
onFileRemove: () => void
}
const FileUploadZone: React.FC<FileUploadZoneProps> = ({
disabled,
onFileSelect,
onFileRemove,
}) => {
return (
<Upload.Dragger
accept={UPLOAD_CONFIG.accept}
maxCount={1}
beforeUpload={(file) => {
onFileSelect(file)
return false
}}
onRemove={() => {
onFileRemove()
}}
showUploadList={false}
disabled={disabled}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}></p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC {UPLOAD_CONFIG.maxSizeMB}MB
</p>
</Upload.Dragger>
)
}
export default FileUploadZone
@@ -0,0 +1,106 @@
import React from "react"
import type { VoiceGender } from "@/pages/voices/types"
import { GENDER_OPTIONS, 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)",
marginBottom: 6,
}
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}
const FormFields: React.FC<FormFieldsProps> = ({
name,
gender,
desc,
disabled,
onNameChange,
onGenderChange,
onDescChange,
}) => {
return (
<>
{/* 名称 */}
<div>
<div style={labelStyle}></div>
<input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
maxLength={UPLOAD_CONFIG.maxNameLength}
disabled={disabled}
style={inputStyle}
/>
</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>
<textarea
value={desc}
onChange={(e) => onDescChange(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={UPLOAD_CONFIG.maxDescLength}
rows={2}
disabled={disabled}
style={{
...inputStyle,
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
</>
)
}
export default FormFields
@@ -0,0 +1,45 @@
import React from "react"
interface UploadProgressProps {
progress: number
}
const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
return (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{progress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{progress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${progress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)
}
export default UploadProgress
@@ -0,0 +1,6 @@
export { default as FileUploadZone } from "./FileUploadZone"
export { default as FileInfoCard } from "./FileInfoCard"
export { default as UploadProgress } from "./UploadProgress"
export { default as FormFields } from "./FormFields"
export { default as ActionButtons } from "./ActionButtons"
export * from "./types"
@@ -0,0 +1,30 @@
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,
maxDescLength: 500,
accept: "audio/*",
} as const
+6
View File
@@ -33,3 +33,9 @@ describe("GeneratePage module smoke test", () => {
expect(true).toBe(true)
})
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
import "@/pages/generate/hooks/generate-video/types"
import "@/pages/generate/hooks/generate-video/phase"
import "@/pages/generate/hooks/generate-video/voiceConfig"
import "@/pages/generate/hooks/generate-video/errorUtils"
+21
View File
@@ -0,0 +1,21 @@
/**
* useGenerateVideo Hook — smoke test
* 确保 vitest related 模式能匹配到 generate-video 目录的改动
*/
import { describe, it, expect } from "vitest"
import type {
UseGenerateVideoProps,
GenerationPhase,
} from "@/pages/generate/hooks/generate-video/types"
import { getNextPhase, PHASE_ORDER } from "@/pages/generate/hooks/generate-video/phase"
import { extractErrorMessage } from "@/pages/generate/hooks/generate-video/errorUtils"
import { getDefaultVoiceConfig } from "@/pages/generate/hooks/generate-video/voiceConfig"
describe("generate-video module smoke test", () => {
it("should load all generate-video modules", () => {
expect(PHASE_ORDER.length).toBeGreaterThan(0)
expect(typeof extractErrorMessage).toBe("function")
expect(typeof getDefaultVoiceConfig).toBe("function")
})
})
@@ -13,6 +13,11 @@ import "@/pages/voice-materials/components/TagSelector"
import "@/pages/voice-materials/components/MaterialForm"
import "@/pages/voice-materials/components/VoiceMaterialCard"
import "@/pages/voice-materials/components/VoiceMaterialRow"
import "@/pages/voice-materials/components/Toolbar"
import "@/pages/voice-materials/components/TagFilterBar"
import "@/pages/voice-materials/components/BatchBar"
import "@/pages/voice-materials/components/EmptyState"
import "@/pages/voice-materials/components/TtsModal"
// 工具函数
import "@/pages/voice-materials/utils/format"
@@ -24,3 +29,10 @@ describe("VoiceMaterialLibrary module smoke test", () => {
expect(true).toBe(true)
})
})
// Hooks
import "@/pages/voice-materials/hooks/useVoiceMaterials"
import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions"
import "@/pages/voice-materials/hooks/useTtsSynthesize"
import "@/pages/voice-materials/hooks/useAudioPlayer"
import "@/pages/voice-materials/hooks/useBatchOperations"
@@ -14,7 +14,20 @@ import "@/pages/voices/components/CloneVoiceCard"
import "@/pages/voices/components/CloneDetailModal"
import "@/pages/voices/components/CloneCardSkeleton"
import "@/pages/voices/components/UploadVoiceModal"
import "@/pages/voices/components/upload-voice-modal/FileUploadZone"
import "@/pages/voices/components/upload-voice-modal/FileInfoCard"
import "@/pages/voices/components/upload-voice-modal/UploadProgress"
import "@/pages/voices/components/upload-voice-modal/FormFields"
import "@/pages/voices/components/upload-voice-modal/ActionButtons"
import "@/pages/voices/components/upload-voice-modal/types"
import "@/pages/voices/components/TtsModal"
import "@/pages/voices/components/tts-modal/TextInputSection"
import "@/pages/voices/components/tts-modal/VoiceSelector"
import "@/pages/voices/components/tts-modal/SpeedControl"
import "@/pages/voices/components/tts-modal/SynthesizeButton"
import "@/pages/voices/components/tts-modal/ErrorAlert"
import "@/pages/voices/components/tts-modal/ResultPanel"
import "@/pages/voices/components/tts-modal/types"
import "@/pages/voices/components/VoiceFilterBar"
import "@/pages/voices/components/MaterialVoiceCard"
+351
View File
@@ -0,0 +1,351 @@
"""BGM 混音纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass
class BGMPureConfig:
"""BGM 混音配置(纯数据类)."""
volume: float = 0.3
fade_in: float = 0.0
fade_out: float = 0.0
loop_enabled: bool = True
sidechain_enabled: bool = False
sidechain_ratio: float = 0.3
sidechain_attack: float = 0.02
sidechain_release: float = 0.5
sidechain_threshold: float = -25.0
# ── 循环判断与计算 ─────────────────────────────────────────────────────────────
def should_loop_bgm(
bgm_duration: float,
target_duration: float,
loop_enabled: bool = True,
) -> bool:
"""判断是否需要循环 BGM.
当 BGM 时长小于目标时长的 90% 时才循环,
避免 BGM 只差一点点就铺满还要循环一次的情况。
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
loop_enabled: 是否允许循环
Returns:
是否需要循环
"""
if not loop_enabled:
return False
if bgm_duration <= 0:
return False
if target_duration <= 0:
return False
return bgm_duration < target_duration * 0.9
def calculate_loop_count(bgm_duration: float, target_duration: float) -> int:
"""计算需要循环的次数.
多算 2 次作为余量,避免末尾因为精度问题不够长。
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
Returns:
循环次数,至少 1
"""
if bgm_duration <= 0:
return 1
if target_duration <= 0:
return 1
if bgm_duration >= target_duration:
return 1
return max(1, int(target_duration / bgm_duration) + 2)
# ── BGM 预处理滤镜链构建 ──────────────────────────────────────────────────────
def build_bgm_filter_chain(
bgm_duration: float,
target_duration: float,
volume: float = 0.3,
fade_in: float = 0.0,
fade_out: float = 0.0,
loop_enabled: bool = True,
) -> str:
"""构建 BGM 预处理滤镜链.
处理顺序:循环 → 音量 → 淡入 → 淡出 → 截断 → 重置时间戳
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
volume: 音量 0.0~1.0
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
loop_enabled: 是否允许循环
Returns:
FFmpeg filter_complex 字符串(逗号分隔)
"""
# 兜底:目标时长不能为 0 或负数
safe_target = max(5.0, target_duration) if target_duration <= 0 else target_duration
filter_parts: list[str] = []
# 1. 循环
needs_loop = should_loop_bgm(bgm_duration, safe_target, loop_enabled)
if needs_loop:
loop_count = calculate_loop_count(bgm_duration, safe_target)
filter_parts.append(f"aloop=loop={loop_count}:size=0")
# 2. 音量调节(钳制到 0~1
safe_volume = max(0.0, min(1.0, volume))
if abs(safe_volume - 1.0) > 0.001:
filter_parts.append(f"volume={safe_volume:.3f}")
# 3. 淡入
if fade_in > 0:
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
# 4. 淡出(从 target_duration - fade_out 开始)
if fade_out > 0 and safe_target > fade_out:
fade_start = safe_target - fade_out
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
# 5. 截断到目标时长
filter_parts.append(f"atrim=0:{safe_target:.3f}")
# 6. 重置时间戳
filter_parts.append("asetpts=N/SR/TB")
return ",".join(filter_parts)
# ── 混音滤镜构建 ──────────────────────────────────────────────────────────────
def calculate_sidechain_ratio(sidechain_ratio: float) -> float:
"""计算 sidechain 压缩比.
sidechain_ratio 表示闪避时 BGM 音量降低比例(0~1),
映射到 FFmpeg sidechaincompress 的 ratio 参数(2:1 ~ 10:1)。
ratio = 1 / (1 - sidechain_ratio)
Args:
sidechain_ratio: 闪避比例 0.0~1.0
Returns:
FFmpeg ratio 值(2.0 ~ 10.0
"""
if sidechain_ratio <= 0:
return 2.0
if sidechain_ratio >= 1.0:
return 10.0
raw_ratio = 1.0 / (1.0 - sidechain_ratio)
return max(2.0, min(10.0, raw_ratio))
def build_simple_mix_filter() -> str:
"""构建普通 amix 混音滤镜.
两路输入:[0:a] 主音频,[1:a] BGM
主音频权重 1.0,BGM 已在预处理阶段调好音量。
amix 会自动归一化,用 volume=2 补偿衰减。
Returns:
filter_complex 字符串
"""
return "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[outa];" "[outa]volume=2[final]"
def build_sidechain_mix_filter(
threshold: float = -25.0,
ratio: float = 0.3,
attack: float = 0.02,
release: float = 0.5,
) -> str:
"""构建 sidechain 人声闪避混音滤镜.
流程:
1. BGM[1:a] 经过 sidechaincompress,用主音频[0:a]做触发
2. 主音频 + 压缩后的 BGM amix 混音
3. volume=1.5 轻微补偿
Args:
threshold: 触发阈值(dB
ratio: 闪避比例 0.0~1.0(会被转换为 FFmpeg ratio
attack: 攻击时间(秒)
release: 释放时间(秒)
Returns:
filter_complex 字符串
"""
ffmpeg_ratio = calculate_sidechain_ratio(ratio)
return (
f"[1:a][0:a]sidechaincompress="
f"threshold={threshold}dB:"
f"ratio={ffmpeg_ratio:.1f}:"
f"attack={attack:.3f}:"
f"release={release:.3f}:"
f"knee=6[bgm_comp];"
f"[0:a][bgm_comp]amix=inputs=2:duration=first:dropout_transition=0[outa];"
f"[outa]volume=1.5[final]"
)
# ── 配置验证与规范化 ──────────────────────────────────────────────────────────
def normalize_bgm_config(config: dict) -> dict:
"""规范化 BGM 配置字典.
将各种类型的输入值转换为正确的类型,
并进行边界钳制。
Args:
config: 原始配置字典
Returns:
规范化后的配置字典
"""
result: dict = {}
# volume: 0.0 ~ 1.0
result["volume"] = max(0.0, min(1.0, float(config.get("volume", 0.3))))
# fade_in: >= 0
result["fade_in"] = max(0.0, float(config.get("fade_in", 0.0)))
# fade_out: >= 0
result["fade_out"] = max(0.0, float(config.get("fade_out", 0.0)))
# loop_enabled: bool
result["loop_enabled"] = bool(config.get("loop_enabled", True))
# sidechain_enabled: bool
result["sidechain_enabled"] = bool(config.get("sidechain_enabled", False))
# sidechain_ratio: 0.0 ~ 1.0
result["sidechain_ratio"] = max(0.0, min(1.0, float(config.get("sidechain_ratio", 0.3))))
# sidechain_attack: > 0
result["sidechain_attack"] = max(0.001, float(config.get("sidechain_attack", 0.02)))
# sidechain_release: > 0
result["sidechain_release"] = max(0.01, float(config.get("sidechain_release", 0.5)))
# sidechain_threshold: dB
result["sidechain_threshold"] = float(config.get("sidechain_threshold", -25.0))
return result
def validate_bgm_config(config: dict) -> tuple[bool, list[str]]:
"""验证 BGM 配置是否合法.
Args:
config: 配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
volume = config.get("volume", 0.3)
if not isinstance(volume, (int, float)):
errors.append("volume 必须是数字")
elif volume < 0 or volume > 1:
errors.append("volume 必须在 0~1 之间")
fade_in = config.get("fade_in", 0)
if not isinstance(fade_in, (int, float)):
errors.append("fade_in 必须是数字")
elif fade_in < 0:
errors.append("fade_in 不能为负数")
fade_out = config.get("fade_out", 0)
if not isinstance(fade_out, (int, float)):
errors.append("fade_out 必须是数字")
elif fade_out < 0:
errors.append("fade_out 不能为负数")
sidechain_ratio = config.get("sidechain_ratio", 0.3)
if not isinstance(sidechain_ratio, (int, float)):
errors.append("sidechain_ratio 必须是数字")
elif sidechain_ratio < 0 or sidechain_ratio > 1:
errors.append("sidechain_ratio 必须在 0~1 之间")
return (len(errors) == 0, errors)
# ── 时长相关工具 ──────────────────────────────────────────────────────────────
def calculate_fade_out_start(
target_duration: float,
fade_out: float,
) -> Optional[float]:
"""计算淡出开始时间.
如果淡出时长大于等于目标时长,返回 None(不做淡出)。
Args:
target_duration: 目标时长(秒)
fade_out: 淡出时长(秒)
Returns:
淡出开始时间(秒),如果不需要淡出返回 None
"""
if fade_out <= 0:
return None
if target_duration <= 0:
return None
if fade_out >= target_duration:
return None
return target_duration - fade_out
def estimate_bgm_processing_duration(
bgm_duration: float,
target_duration: float,
loop_enabled: bool = True,
) -> float:
"""估算 BGM 预处理后的实际输出时长.
正常情况下应该等于 target_duration
但在某些边界情况下可能不同。
Args:
bgm_duration: BGM 原始时长
target_duration: 目标时长
loop_enabled: 是否允许循环
Returns:
预估输出时长(秒)
"""
if target_duration <= 0:
return 5.0 # 兜底时长
# 不需要循环的情况:如果 BGM 够长,截断到 target_duration
if not loop_enabled and bgm_duration >= target_duration:
return target_duration
# 需要循环或 BGM 太短:截断到 target_duration
return target_duration
+493
View File
@@ -0,0 +1,493 @@
"""视频拼接引擎纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
# ── 帧率解析 ──────────────────────────────────────────────────────────────────
def parse_fps(fps_value: Any) -> float:
"""解析帧率字符串/数值.
支持格式:
- 数字: 30 → 30.0
- 分数: "30/1" → 30.0, "24000/1001" → 23.976...
- 字符串数字: "30" → 30.0
Args:
fps_value: 帧率值(字符串、数字等)
Returns:
帧率(fps),失败返回 30.0
"""
if fps_value is None:
return 30.0
try:
fps_str = str(fps_value).strip()
if not fps_str:
return 30.0
if "/" in fps_str:
num_str, den_str = fps_str.split("/", 1)
num = float(num_str)
den = float(den_str)
if den == 0:
return 30.0
return num / den
return float(fps_str)
except (ValueError, TypeError, ZeroDivisionError):
return 30.0
def format_fps_filter(fps: float) -> str:
"""格式化 fps 滤镜参数.
Args:
fps: 帧率
Returns:
fps 滤镜字符串
"""
# 接近整数时用整数形式
if abs(fps - round(fps)) < 0.001:
return f"fps={int(fps)}"
return f"fps={fps:.3f}"
# ── 输出参数计算 ──────────────────────────────────────────────────────────────
def resolve_output_params(
config_width: int,
config_height: int,
config_fps: float,
first_video_info: Optional[dict] = None,
default_width: int = 1080,
default_height: int = 1920,
default_fps: float = 30.0,
) -> tuple[int, int, float]:
"""计算输出视频参数.
优先级:
1. config 中显式指定的(非 0 值)
2. 第一段视频的探测参数
3. 默认值
Args:
config_width: 配置的宽度(0 表示未指定)
config_height: 配置的高度(0 表示未指定)
config_fps: 配置的帧率(0 表示未指定)
first_video_info: 第一段视频的探测信息字典
default_width: 默认宽度
default_height: 默认高度
default_fps: 默认帧率
Returns:
(宽度, 高度, 帧率)
"""
width = config_width
height = config_height
fps = config_fps
info = first_video_info or {}
# 宽度:用配置 → 探测 → 默认
if width == 0:
width = int(info.get("width", default_width))
# 高度
if height == 0:
height = int(info.get("height", default_height))
# 帧率
if fps == 0:
fps_str = info.get("r_frame_rate", f"{int(default_fps)}/1")
fps = parse_fps(fps_str)
# 确保都是有效值
width = max(1, width)
height = max(1, height)
fps = max(1.0, fps)
return width, height, fps
def calculate_scaled_size(
src_w: int,
src_h: int,
target_w: int,
target_h: int,
) -> tuple[int, int, int, int]:
"""计算等比缩放后的尺寸和填充偏移.
保持宽高比,不足的部分用黑边填充。
Args:
src_w: 原始宽度
src_h: 原始高度
target_w: 目标宽度
target_h: 目标高度
Returns:
(缩放后宽度, 缩放后高度, X偏移, Y偏移)
"""
if src_w <= 0 or src_h <= 0:
return (target_w, target_h, 0, 0)
src_ratio = src_w / src_h
target_ratio = target_w / target_h
if abs(src_ratio - target_ratio) < 0.001:
# 比例相同,直接缩放
return (target_w, target_h, 0, 0)
elif src_ratio > target_ratio:
# 源更宽,以宽度为准,上下填充
scaled_w = target_w
scaled_h = int(target_w / src_ratio)
offset_x = 0
offset_y = (target_h - scaled_h) // 2
return (scaled_w, scaled_h, offset_x, offset_y)
else:
# 源更高,以高度为准,左右填充
scaled_h = target_h
scaled_w = int(target_h * src_ratio)
offset_x = (target_w - scaled_w) // 2
offset_y = 0
return (scaled_w, scaled_h, offset_x, offset_y)
# ── stream copy 判断 ─────────────────────────────────────────────────────────
def can_use_stream_copy(
segments: list[dict],
target_width: int,
target_height: int,
target_fps: float,
force_reencode: bool = False,
) -> bool:
"""判断是否可以使用 stream copy(无损拼接).
stream copy 条件:
1. force_reencode 为 False
2. 所有视频段的编码格式、分辨率、帧率均相同
3. 目标参数与源参数一致(不需要转码)
Args:
segments: 视频段列表,每个元素包含 codec_name/width/height/fps
target_width: 目标宽度
target_height: 目标高度
target_fps: 目标帧率
force_reencode: 是否强制重编码
Returns:
是否可以用 stream copy
"""
if force_reencode:
return False
if not segments:
return False
# 用第一段作为基准
first = segments[0]
base_codec = first.get("codec_name", "")
base_width = int(first.get("width", 0))
base_height = int(first.get("height", 0))
base_fps = parse_fps(first.get("r_frame_rate", "30/1"))
# 目标参数必须与基准一致
if target_width != base_width or target_height != base_height:
return False
if abs(target_fps - base_fps) > 0.01:
return False
# 所有段必须参数一致
for seg in segments[1:]:
if seg.get("codec_name", "") != base_codec:
return False
if int(seg.get("width", 0)) != base_width:
return False
if int(seg.get("height", 0)) != base_height:
return False
seg_fps = parse_fps(seg.get("r_frame_rate", "30/1"))
if abs(seg_fps - base_fps) > 0.01:
return False
return True
# ── 文件列表生成(demuxer 模式) ───────────────────────────────────────────────
def generate_concat_file_list(
video_paths: list[str],
) -> str:
"""生成 concat demuxer 模式的文件列表内容.
格式:
file '/path/to/video1.mp4'
file '/path/to/video2.mp4'
Args:
video_paths: 视频文件路径列表
Returns:
文件列表文本内容
"""
lines = []
for path in video_paths:
# 转义单引号
escaped = path.replace("'", "'\\''")
lines.append(f"file '{escaped}'")
return "\n".join(lines) + "\n"
# ── 滤镜链构建 ────────────────────────────────────────────────────────────────
def build_scale_pad_filter(
target_w: int,
target_h: int,
src_w: int = 0,
src_h: int = 0,
) -> str:
"""构建 scale + pad 滤镜(等比缩放+黑边填充).
Args:
target_w: 目标宽度
target_h: 目标高度
src_w: 源宽度(0 表示未知,用 iw/ih)
src_h: 源高度(0 表示未知)
Returns:
滤镜字符串
"""
# 使用 FFmpeg 表达式,动态计算
return (
f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,"
f"pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:black"
)
def build_fps_filter(fps: float) -> str:
"""构建 fps 滤镜.
Args:
fps: 目标帧率
Returns:
fps 滤镜字符串
"""
return format_fps_filter(fps)
def build_setpts_filter() -> str:
"""构建 setpts 滤镜(重置时间戳).
Returns:
setpts 滤镜字符串
"""
return "setpts=PTS-STARTPTS"
def build_concat_filter(
num_inputs: int,
has_audio: bool = True,
) -> str:
"""构建 concat 滤镜.
Args:
num_inputs: 输入数量
has_audio: 是否包含音频轨
Returns:
concat 滤镜字符串(包含输入标签)
"""
if num_inputs <= 0:
return ""
n = num_inputs
v = 1 # 视频轨数
a = 1 if has_audio else 0 # 音频轨数
# 构建输入标签
input_labels = "".join(f"[{i}:v][{i}:a]" if has_audio else f"[{i}:v]" for i in range(n))
output_label = "[concat_v]" + ("[concat_a]" if has_audio else "")
return f"{input_labels}concat=n={n}:v={v}:a={a}{output_label}"
def build_single_segment_filter_chain(
target_width: int,
target_height: int,
target_fps: float,
segment_index: int,
has_audio: bool = True,
) -> str:
"""构建单段视频的预处理滤镜链.
每段视频需要:缩放填充 → 帧率统一 → 重置时间戳
Args:
target_width: 目标宽度
target_height: 目标高度
target_fps: 目标帧率
segment_index: 段索引(用于生成标签)
has_audio: 是否包含音频
Returns:
滤镜字符串
"""
scale_pad = build_scale_pad_filter(target_width, target_height)
fps = build_fps_filter(target_fps)
setpts = build_setpts_filter()
input_v = f"[{segment_index}:v]"
output_v = f"[v{segment_index}]"
video_chain = f"{input_v}{scale_pad},{fps},{setpts}{output_v}"
if has_audio:
input_a = f"[{segment_index}:a]"
output_a = f"[a{segment_index}]"
# 音频也需要重置时间戳
audio_chain = f"{input_a}asetpts=PTS-STARTPTS{output_a}"
return f"{video_chain};{audio_chain}"
return video_chain
# ── 配置验证 ──────────────────────────────────────────────────────────────────
def validate_concat_config(config: dict) -> tuple[bool, list[str]]:
"""验证拼接配置.
Args:
config: 配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
segments = config.get("segments", [])
if not segments:
errors.append("至少需要一个视频段")
return (False, errors)
if len(segments) < 1:
errors.append("视频段数量不能少于 1")
# 检查每个段
for i, seg in enumerate(segments):
video_path = seg.get("video_path", "")
if not video_path:
errors.append(f"{i+1} 段缺少 video_path")
# 输出参数
output_width = config.get("output_width", 0)
output_height = config.get("output_height", 0)
if output_width < 0:
errors.append("output_width 不能为负数")
if output_height < 0:
errors.append("output_height 不能为负数")
output_fps = config.get("output_fps", 0)
if output_fps < 0:
errors.append("output_fps 不能为负数")
return (len(errors) == 0, errors)
# ── 路径验证 ──────────────────────────────────────────────────────────────────
def validate_video_path(video_path: str, work_dir: str | Path) -> tuple[bool, str]:
"""验证视频路径是否安全.
检查:
1. 路径不为空
2. 路径不包含 .. 回溯
3. 路径在 work_dir 内(安全边界)
Args:
video_path: 视频文件路径
work_dir: 工作目录
Returns:
(是否合法, 错误信息)
"""
if not video_path:
return (False, "视频路径不能为空")
path_str = str(video_path)
work_str = str(work_dir)
# 检查路径遍历
if ".." in Path(path_str).parts:
return (False, "视频路径不能包含 .. 回溯")
# 绝对路径才做边界检查;相对路径默认相对于 work_dir
if not Path(path_str).is_absolute():
return (True, "")
# 绝对路径检查是否在工作目录内
try:
video_abs = Path(path_str).resolve()
work_abs = Path(work_str).resolve()
if work_abs.is_absolute() and not str(video_abs).startswith(str(work_abs)):
return (False, "视频路径必须在工作目录内")
except (OSError, ValueError):
pass # 解析失败时跳过边界检查
return (True, "")
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def estimate_total_duration(segments: list[dict]) -> float:
"""估算总时长.
Args:
segments: 视频段列表,每个元素包含 duration 字段
Returns:
总时长(秒)
"""
total = 0.0
for seg in segments:
dur = seg.get("duration", 0)
try:
total += float(dur)
except (ValueError, TypeError):
pass
return total
def count_valid_segments(segments: list[dict]) -> int:
"""统计有效视频段数量(有 video_path 的).
Args:
segments: 视频段列表
Returns:
有效段数量
"""
count = 0
for seg in segments:
if seg.get("video_path"):
count += 1
return count
+466
View File
@@ -0,0 +1,466 @@
"""多轨混音纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
from typing import Any, Optional
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
def calculate_effective_range(
track_start: float,
track_duration: float,
audio_duration: float,
target_duration: float,
) -> tuple[float, float, float]:
"""计算轨道的有效时间范围.
处理:
- 轨道时长为 0 或负时用音频完整时长
- 轨道开始在目标时长外时跳过
- 轨道开始为负时截断开头
Args:
track_start: 轨道开始时间(秒),可为负
track_duration: 轨道持续时长(秒),<=0 表示用音频全长
audio_duration: 音频文件实际时长(秒)
target_duration: 目标总时长(秒)
Returns:
(effective_start, need_duration, trim_start)
- effective_start: 在目标时间轴上的开始位置(>=0)
- need_duration: 需要截取的音频长度
- trim_start: 从源音频的哪个位置开始截取
"""
if audio_duration <= 0:
return (0.0, 0.0, 0.0)
# 有效时长(轨道声明的时长,未被截断的)
if track_duration > 0:
effective_dur = min(track_duration, audio_duration)
else:
effective_dur = audio_duration
effective_start = track_start
trim_start = 0.0
# 负的开始时间:从源音频中间开始取,轨道前段被截掉
if effective_start < 0:
trim_start = -effective_start
# 可用时长 = 总时长 - 被截掉的前段
effective_dur = max(0.0, effective_dur - trim_start)
effective_start = 0.0
# 轨道完全在目标时长之外
if effective_start >= target_duration:
return (0.0, 0.0, 0.0)
# 轨道完全在 0 之前
if effective_start + effective_dur <= 0:
return (0.0, 0.0, 0.0)
# 实际需要的源时长
need_dur = min(effective_dur, target_duration - effective_start)
if need_dur <= 0:
return (0.0, 0.0, 0.0)
# 调整 trim_start 不能超过音频长度
if trim_start >= audio_duration:
return (0.0, 0.0, 0.0)
return (effective_start, need_dur, trim_start)
def is_track_visible(
track_start: float,
track_duration: float,
audio_duration: float,
target_duration: float,
) -> bool:
"""判断轨道是否在目标时长范围内可见(有声音).
Args:
track_start: 轨道开始时间
track_duration: 轨道持续时长
audio_duration: 音频时长
target_duration: 目标总时长
Returns:
是否可见
"""
_, need_dur, _ = calculate_effective_range(track_start, track_duration, audio_duration, target_duration)
return need_dur > 0
# ── 单轨滤镜链构建 ────────────────────────────────────────────────────────────
def build_track_filter_chain(
volume: float,
fade_in: float,
fade_out: float,
effective_start: float,
need_duration: float,
trim_start: float,
target_duration: float,
) -> str:
"""构建单轨道预处理滤镜链.
处理顺序:截断 → 重置时间戳 → 音量 → 淡入 → 淡出 → 延迟 → 最终截断 → 重置时间戳
Args:
volume: 音量 0.0~1.0
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
effective_start: 在目标轴上的开始时间
need_duration: 需要截取的时长
trim_start: 从源音频的哪个位置开始
target_duration: 目标总时长
Returns:
逗号分隔的滤镜字符串
"""
filter_parts: list[str] = []
# 1. 截断到有效范围
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB")
# 2. 音量调节
safe_volume = max(0.0, min(2.0, volume))
if abs(safe_volume - 1.0) > 0.001:
filter_parts.append(f"volume={safe_volume:.3f}")
# 3. 淡入(必须小于总时长才有效)
if fade_in > 0 and fade_in < need_duration:
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
# 4. 淡出
if fade_out > 0 and fade_out < need_duration:
fade_start = need_duration - fade_out
if fade_start > 0:
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
# 5. 时间偏移(开头静音填充)
if effective_start > 0.01:
delay_ms = int(effective_start * 1000)
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
# 6. 最终截断到目标总时长
filter_parts.append(f"atrim=0:{target_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB")
return ",".join(filter_parts)
# ── amix 混音滤镜构建 ────────────────────────────────────────────────────────
def build_amix_filter(num_inputs: int, duration_mode: str = "longest") -> str:
"""构建 amix 混音滤镜.
Args:
num_inputs: 输入轨道数量
duration_mode: 时长模式:longest / shortest / first
Returns:
amix 滤镜字符串
"""
if num_inputs <= 0:
return ""
# 校验 duration_mode
if duration_mode not in ("longest", "shortest", "first"):
duration_mode = "longest"
return f"amix=inputs={num_inputs}:duration={duration_mode}:dropout_transition=0"
def calculate_amix_volume_compensation(num_inputs: int) -> float:
"""计算 amix 后的音量补偿系数.
amix 会将 N 路输入每路乘以 1/N 来归一化,
所以需要乘以 N 来补偿(简单粗暴但有效)。
Args:
num_inputs: 输入轨道数量
Returns:
补偿系数
"""
if num_inputs <= 1:
return 1.0
return float(num_inputs)
def build_mix_filter_complex(
num_tracks: int,
has_main: bool = True,
duration_mode: str = "longest",
) -> str:
"""构建完整的混音 filter_complex.
Args:
num_tracks: 额外轨道数量
has_main: 是否有主音频
duration_mode: 时长模式
Returns:
filter_complex 字符串
"""
total_inputs = num_tracks + (1 if has_main else 0)
if total_inputs <= 0:
return ""
# 输入标签
input_labels = "".join(f"[{i}:a]" for i in range(total_inputs))
# amix
amix = build_amix_filter(total_inputs, duration_mode)
# 音量补偿
compensation = calculate_amix_volume_compensation(total_inputs)
volume_filter = ""
if abs(compensation - 1.0) > 0.001:
volume_filter = f",volume={compensation}"
return f"{input_labels}{amix}{volume_filter}[mixed]"
# ── 音量计算 ──────────────────────────────────────────────────────────────────
def normalize_volume(volume: float) -> float:
"""规范化音量值.
Args:
volume: 原始音量
Returns:
规范化后的音量(0.0 ~ 2.0)
"""
if volume is None:
return 1.0
try:
v = float(volume)
return max(0.0, min(2.0, v))
except (ValueError, TypeError):
return 1.0
def db_to_linear(db: float) -> float:
"""dB 转换为线性音量.
Args:
db: 分贝值
Returns:
线性音量值
"""
import math
return 10 ** (db / 20.0)
def linear_to_db(linear: float) -> float:
"""线性音量转换为 dB.
Args:
linear: 线性音量值
Returns:
分贝值
"""
import math
if linear <= 0:
return -float("inf")
return 20.0 * math.log10(linear)
# ── 轨道排序与过滤 ────────────────────────────────────────────────────────────
def sort_tracks_by_priority(
tracks: list[dict],
) -> list[dict]:
"""按优先级排序轨道.
priority 数字越小优先级越高(越先播放/越底层)。
相同优先级保持原顺序。
Args:
tracks: 轨道配置列表
Returns:
排序后的轨道列表
"""
return sorted(tracks, key=lambda t: int(t.get("priority", 100)))
def filter_enabled_tracks(tracks: list[dict]) -> list[dict]:
"""过滤出启用的轨道.
Args:
tracks: 轨道列表
Returns:
启用的轨道列表
"""
result = []
for t in tracks:
enabled = t.get("enabled", True)
if bool(enabled) and enabled != "false" and enabled != 0:
result.append(t)
return result
def count_track_types(tracks: list[dict]) -> dict[str, int]:
"""统计各类型轨道数量.
Args:
tracks: 轨道列表
Returns:
类型计数字典
"""
counts: dict[str, int] = {}
for t in tracks:
ttype = t.get("track_type", "unknown")
counts[ttype] = counts.get(ttype, 0) + 1
return counts
# ── 配置验证 ──────────────────────────────────────────────────────────────────
def validate_audio_track(track: dict) -> tuple[bool, list[str]]:
"""验证单条音轨配置.
Args:
track: 轨道配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
# 音频路径
audio_path = track.get("audio_path", "")
if not audio_path and not track.get("asset_id"):
errors.append("轨道需要 audio_path 或 asset_id")
# 音量范围
volume = track.get("volume", 1.0)
try:
v = float(volume)
if v < 0:
errors.append("volume 不能为负数")
if v > 2.0:
errors.append("volume 建议不超过 2.0")
except (ValueError, TypeError):
errors.append("volume 必须是数字")
# 淡入淡出
fade_in = track.get("fade_in", 0)
fade_out = track.get("fade_out", 0)
try:
if float(fade_in) < 0:
errors.append("fade_in 不能为负数")
except (ValueError, TypeError):
errors.append("fade_in 必须是数字")
try:
if float(fade_out) < 0:
errors.append("fade_out 不能为负数")
except (ValueError, TypeError):
errors.append("fade_out 必须是数字")
# 开始时间
start_time = track.get("start_time", 0)
try:
float(start_time) # 验证是否为数字
except (ValueError, TypeError):
errors.append("start_time 必须是数字")
return (len(errors) == 0, errors)
def validate_mix_config(config: dict) -> tuple[bool, list[str]]:
"""验证混音配置.
Args:
config: 混音配置
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
tracks = config.get("tracks", [])
if not tracks:
errors.append("至少需要一条轨道")
# 验证每条轨道
for i, track in enumerate(tracks):
ok, track_errors = validate_audio_track(track)
if not ok:
for err in track_errors:
errors.append(f"{i+1}轨:{err}")
# 目标时长
target_duration = config.get("target_duration", 0)
try:
if float(target_duration) < 0:
errors.append("target_duration 不能为负数")
except (ValueError, TypeError):
errors.append("target_duration 必须是数字")
return (len(errors) == 0, errors)
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def calculate_total_tracks(config: dict) -> int:
"""计算总轨道数(含主音频).
Args:
config: 混音配置
Returns:
总轨道数
"""
tracks = config.get("tracks", [])
has_main = config.get("has_main_audio", True)
count = len(tracks)
if has_main:
count += 1
return count
def estimate_mix_duration(tracks: list[dict]) -> float:
"""估算混音总时长(所有轨道的最晚结束时间).
Args:
tracks: 轨道列表,包含 start_time 和 duration
Returns:
估算总时长(秒)
"""
max_end = 0.0
for t in tracks:
try:
start = float(t.get("start_time", 0))
dur = float(t.get("duration", 0))
if dur > 0:
end = start + dur
if end > max_end:
max_end = end
except (ValueError, TypeError):
continue
return max_end
+5 -5
View File
@@ -15,17 +15,17 @@ from __future__ import annotations
from typing import Optional
from packages.domain.speed_config import ( # noqa: F401 — 向后兼容
from packages.domain.speed_config import (
DEFAULT_SPEED,
MAX_SPEED,
MIN_SPEED,
SpeedConfig,
_split_atempo_stages,
adjust_duration as _adjust_duration_base,
build_audio_filter as _build_audio_filter_base,
build_video_filter as _build_video_filter_base,
resolve_clip_speed as _resolve_clip_speed_base,
)
from packages.domain.speed_config import adjust_duration as _adjust_duration_base # noqa: F401 — 向后兼容
from packages.domain.speed_config import build_audio_filter as _build_audio_filter_base
from packages.domain.speed_config import build_video_filter as _build_video_filter_base
from packages.domain.speed_config import resolve_clip_speed as _resolve_clip_speed_base
class SpeedEngine:
+534
View File
@@ -0,0 +1,534 @@
"""贴纸引擎纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
from typing import Any, Optional
# ── 安全类型转换 ──────────────────────────────────────────────────────────────
def safe_float(val: Any) -> Optional[float]:
"""安全转换为 float.
Args:
val: 任意值
Returns:
float 值,失败返回 None
"""
if val is None:
return None
try:
result = float(val)
if result != result: # NaN check
return None
return result
except (ValueError, TypeError):
return None
def safe_int(val: Any, default: int = 0) -> int:
"""安全转换为 int.
Args:
val: 任意值
default: 默认值
Returns:
int 值,失败返回默认值
"""
if val is None:
return default
try:
result = int(float(val))
return result
except (ValueError, TypeError):
return default
def safe_bool(val: Any) -> bool:
"""安全转换为 bool.
Args:
val: 任意值
Returns:
bool 值
"""
if isinstance(val, bool):
return val
if val is None:
return False
if isinstance(val, str):
return val.lower() in ("true", "1", "yes", "on")
return bool(val)
# ── 尺寸估算 ──────────────────────────────────────────────────────────────────
def estimate_sticker_size(
canvas_w: int,
canvas_h: int,
scale: float = 1.0,
fixed_width: Optional[int] = None,
fixed_height: Optional[int] = None,
) -> tuple[int, int]:
"""估算贴纸尺寸.
如果指定了固定宽高,直接使用;否则按画布的 30% * scale 估算。
Args:
canvas_w: 画布宽度
canvas_h: 画布高度
scale: 缩放比例
fixed_width: 固定宽度(可选)
fixed_height: 固定高度(可选)
Returns:
(估算宽度, 估算高度)
"""
if fixed_width and fixed_height:
return (fixed_width, fixed_height)
base_ratio = 0.3
est_w = int(canvas_w * base_ratio * scale) if not fixed_width else fixed_width
est_h = int(canvas_h * base_ratio * scale) if not fixed_height else fixed_height
return (max(1, est_w), max(1, est_h))
def estimate_text_size(
text: str,
font_size: int,
) -> tuple[int, int]:
"""估算文字贴纸尺寸.
粗略估算:宽度 = 字数 * 字号 * 0.6,高度 = 字号 * 1.4
Args:
text: 文字内容
font_size: 字号
Returns:
(估算宽度, 估算高度)
"""
if not text:
return (0, 0)
est_w = int(len(text) * font_size * 0.6)
est_h = int(font_size * 1.4)
return (max(1, est_w), max(1, est_h))
# ── 时间计算 ──────────────────────────────────────────────────────────────────
def calculate_fade_out_start(
start_time: float,
duration: float,
fade_out: float,
) -> float:
"""计算淡出开始时间.
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
fade_out: 淡出时长(秒)
Returns:
淡出开始时间(秒),最小为 0
"""
if fade_out <= 0 or duration <= 0:
return 0.0
fade_start = start_time + duration - fade_out
return max(0.0, fade_start)
def calculate_end_time(start_time: float, duration: float) -> float:
"""计算结束时间.
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
Returns:
结束时间(秒)
"""
if duration <= 0:
return start_time
return start_time + duration
def has_time_range(duration: float) -> bool:
"""是否有时间范围限制.
Args:
duration: 持续时长(秒)
Returns:
duration > 0 时返回 True
"""
return duration > 0
# ── 滤镜组件构建 ──────────────────────────────────────────────────────────────
def build_scale_filter(
width: Optional[int] = None,
height: Optional[int] = None,
scale: float = 1.0,
) -> Optional[str]:
"""构建缩放滤镜.
优先使用固定宽高,否则按比例缩放。
scale=1.0 且无固定尺寸时返回 None。
Args:
width: 固定宽度(可选)
height: 固定高度(可选)
scale: 缩放比例
Returns:
scale 滤镜字符串,不需要缩放时返回 None
"""
if width and height:
return f"scale={width}:{height}"
if scale != 1.0:
return f"scale=iw*{scale}:ih*{scale}"
return None
def build_opacity_filter(opacity: float) -> Optional[str]:
"""构建透明度滤镜.
Args:
opacity: 不透明度 0.0~1.0
Returns:
colorchannelmixer 滤镜字符串,完全不透明时返回 None
"""
if opacity >= 1.0:
return None
safe_opacity = max(0.0, min(1.0, opacity))
return f"colorchannelmixer=aa={safe_opacity}"
def build_image_fade_filters(
start_time: float,
duration: float,
fade_in: float = 0.0,
fade_out: float = 0.0,
) -> list[str]:
"""构建图片贴纸淡入淡出滤镜列表.
使用 FFmpeg fade 滤镜(alpha 通道)。
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
Returns:
fade 滤镜字符串列表
"""
filters: list[str] = []
if fade_in > 0:
filters.append(f"fade=in:st={start_time}:d={fade_in}:alpha=1")
if fade_out > 0 and duration > 0:
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
filters.append(f"fade=out:st={fade_out_start}:d={fade_out}:alpha=1")
return filters
def build_enable_expr(
start_time: float,
duration: float,
) -> str:
"""构建 enable 表达式(时间范围).
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
Returns:
enable 表达式字符串(包含开头的冒号),无时间限制时返回空字符串
"""
if duration <= 0:
return ""
end_time = calculate_end_time(start_time, duration)
return f":enable='between(t,{start_time},{end_time})'"
# ── drawtext 文字贴纸相关 ─────────────────────────────────────────────────────
def escape_drawtext_text(text: str) -> str:
"""转义 drawtext 中的特殊字符.
转义冒号和单引号。
Args:
text: 原始文字
Returns:
转义后的文字
"""
result = text.replace(":", "\\:")
result = result.replace("'", "\\'")
return result
def build_drawtext_alpha_expr(
start_time: float,
duration: float,
fade_in: float = 0.0,
fade_out: float = 0.0,
) -> str:
"""构建 drawtext 的 alpha 淡入淡出表达式.
drawtext 没有直接的 fade 滤镜,用 alpha 表达式模拟。
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
Returns:
alpha 表达式字符串,无淡入淡出时返回 "1"
"""
parts: list[str] = []
if fade_in > 0:
fade_in_end = start_time + fade_in
parts.append(f"if(lt(t,{fade_in_end}),(t-{start_time})/{fade_in},1)")
if fade_out > 0 and duration > 0:
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
end_time = calculate_end_time(start_time, duration)
parts.append(f"if(gt(t,{fade_out_start}),({end_time}-t)/{fade_out},1)")
if not parts:
return "1"
return "*".join(parts)
def build_stroke_params(
stroke_width: int = 0,
stroke_color: str = "black",
) -> list[str]:
"""构建 drawtext 描边参数.
Args:
stroke_width: 描边宽度(0 表示无描边)
stroke_color: 描边颜色
Returns:
描边参数列表
"""
if stroke_width <= 0:
return []
return [
f"borderw={stroke_width}",
f"bordercolor={stroke_color}",
]
def build_shadow_params(
shadow_alpha: float = 0.0,
shadow_x: int = 2,
shadow_y: int = 2,
shadow_color: str = "black",
) -> list[str]:
"""构建 drawtext 阴影参数.
Args:
shadow_alpha: 阴影透明度(0 表示无阴影)
shadow_x: 阴影 X 偏移
shadow_y: 阴影 Y 偏移
shadow_color: 阴影颜色
Returns:
阴影参数列表
"""
if shadow_alpha <= 0:
return []
safe_alpha = max(0.0, min(1.0, shadow_alpha))
return [
f"shadowx={shadow_x}",
f"shadowy={shadow_y}",
f"shadowcolor={shadow_color}@{safe_alpha}",
]
# ── 贴纸排序与过滤 ────────────────────────────────────────────────────────────
def sort_stickers_by_z_index(
stickers: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""按 z_index 排序贴纸.
z_index 小的在底层,大的在上层。
相同 z_index 保持原顺序(稳定排序)。
Args:
stickers: 贴纸配置列表
Returns:
排序后的贴纸列表
"""
return sorted(stickers, key=lambda s: safe_int(s.get("z_index"), 10))
def filter_enabled_stickers(
stickers: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""过滤出启用的贴纸.
Args:
stickers: 贴纸配置列表
Returns:
启用的贴纸列表
"""
result = []
for s in stickers:
enabled = s.get("enabled", True)
if safe_bool(enabled):
result.append(s)
return result
def count_sticker_types(
stickers: list[dict[str, Any]],
) -> dict[str, int]:
"""统计各类型贴纸数量.
Args:
stickers: 贴纸配置列表
Returns:
类型计数字典
"""
counts: dict[str, int] = {}
for s in stickers:
stype = s.get("type", "image")
counts[stype] = counts.get(stype, 0) + 1
return counts
# ── overlay 滤镜构建 ──────────────────────────────────────────────────────────
def build_overlay_position(
pos_x: float,
pos_y: float,
) -> str:
"""构建 overlay 位置参数.
Args:
pos_x: X 坐标
pos_y: Y 坐标
Returns:
overlay 位置字符串 "x:y"
"""
return f"{pos_x:.0f}:{pos_y:.0f}"
def build_pre_filter_label(idx: int) -> str:
"""构建贴纸预处理后的标签名.
Args:
idx: 贴纸索引
Returns:
滤镜标签字符串
"""
return f"sticker_{idx}_scaled"
# ── 验证函数 ──────────────────────────────────────────────────────────────────
def validate_image_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
"""验证图片贴纸配置.
Args:
sticker: 贴纸配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
# 图片路径
image_path = sticker.get("image_path", "")
if not image_path and not sticker.get("asset_id"):
errors.append("图片贴纸需要 image_path 或 asset_id")
# 透明度范围
opacity = safe_float(sticker.get("opacity", 1.0))
if opacity is not None and (opacity < 0 or opacity > 1):
errors.append("opacity 必须在 0~1 之间")
# 缩放比例
scale = safe_float(sticker.get("scale", 1.0))
if scale is not None and scale <= 0:
errors.append("scale 必须大于 0")
# 时间参数
duration = safe_float(sticker.get("duration", 0))
if duration is not None and duration < 0:
errors.append("duration 不能为负数")
start_time = safe_float(sticker.get("start_time", 0))
if start_time is not None and start_time < 0:
errors.append("start_time 不能为负数")
return (len(errors) == 0, errors)
def validate_text_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
"""验证文字贴纸配置.
Args:
sticker: 贴纸配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
# 文字内容
text = sticker.get("text", "")
if not text:
errors.append("文字贴纸需要 text 内容")
# 字号
font_size = safe_int(sticker.get("font_size", 36))
if font_size <= 0:
errors.append("font_size 必须大于 0")
# 颜色
font_color = sticker.get("font_color", "white")
if not font_color:
errors.append("font_color 不能为空")
# 时间参数
duration = safe_float(sticker.get("duration", 0))
if duration is not None and duration < 0:
errors.append("duration 不能为负数")
return (len(errors) == 0, errors)
@@ -0,0 +1 @@
PAGE_COUNT=$(echo "$PAGE_FILES"
+14 -12
View File
@@ -25,19 +25,21 @@ from urllib.parse import urljoin
from packages.domain.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
ALLOWED_IMAGE_MIME_TYPES,
ALLOWED_PORTS as _allowed_ports_base,
ALLOWED_SCHEMES as _allowed_schemes_base,
ALLOWED_VIDEO_MIME_TYPES,
MAX_URL_LENGTH,
MAGIC_NUMBERS,
UrlSecurityError as _UrlSecurityError_base,
check_internal_hostname as _check_internal_hostname_base,
check_ssrf_ip as _check_ssrf_ip_base,
is_ip_address as _is_ip_address_base,
is_trusted_domain as _is_trusted_domain_base,
validate_magic_number as _validate_magic_number_base,
validate_url_basic as _validate_url_basic_base,
)
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
from packages.domain.url_security import (
ALLOWED_VIDEO_MIME_TYPES,
MAGIC_NUMBERS,
MAX_URL_LENGTH,
)
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base
from packages.domain.url_security import is_ip_address as _is_ip_address_base
from packages.domain.url_security import is_trusted_domain as _is_trusted_domain_base
from packages.domain.url_security import validate_magic_number as _validate_magic_number_base
from packages.domain.url_security import validate_url_basic as _validate_url_basic_base
logger = logging.getLogger(__name__)
+24 -33
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
- PR事件:自动修复并push回PR源分支(Agent提交的PR自动修,人提交的仅诊断)
- PR事件:所有PR只要Code Quality因格式问题失败,自动修复并push回源分支
- Push事件(develop/main):自动修复并push回原分支,保持主干格式永远正确
- 防循环:修复commit带 [skip ci-format-check] 标记,检测到该标记则跳过修复
- 只修格式(black/isort/prettier),ruff逻辑类错误不动
当code quality检查因格式问题失败时触发。
"""
@@ -239,7 +241,7 @@ def main():
print("无法获取PR号,跳过自动修复")
return
# 获取PR作者信息,判断是人还是Agent提交的
# 获取PR信息
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req_pr) as resp:
@@ -247,17 +249,26 @@ def main():
pr_author = pr_info.get("user", {}).get("login", "")
print(f"PR作者: {pr_author}")
# 判断是否为Agent提交的PR
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
# 防循环检测:检查最新commit是否已经是格式修复commit
# 修复commit message 带 [skip ci-format-check] 标记,检测到则跳过
head_branch_tmp = pr_info.get("head", {}).get("ref", "")
skip_marker = "[skip ci-format-check]"
try:
commits_url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits?limit=3"
req_commits = urllib.request.Request(commits_url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req_commits) as resp_commits:
commits = json.loads(resp_commits.read())
latest_msg = commits[0].get("commit", {}).get("message", "") if commits else ""
if skip_marker in latest_msg:
print(f"检测到最新commit包含 {skip_marker} 标记,跳过格式修复(防循环)")
print("本次格式检查失败是格式修复commit触发的CI回跑,属正常现象")
sys.exit(0)
except Exception as e:
print(f"⚠️ 防循环检测失败,继续执行: {e}")
if is_agent_pr:
print(f"检测到Agent提交的PR(作者: {pr_author},将自动修复并推送")
fix_mode = "auto_fix_and_push"
else:
print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改")
print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)")
fix_mode = "diagnose_only"
# 所有PR都自动修复格式(不再区分人/Agent)
print("检测到格式问题,将自动修复并推送回分支")
fix_mode = "auto_fix_and_push"
print("=== 检测到代码格式问题,尝试自动修复 ===")
print(f"PR #{pr_number}")
@@ -315,26 +326,6 @@ def main():
print("没有需要提交的格式改动")
return
# 诊断模式:只报告问题,不修改不推送
if fix_mode == "diagnose_only":
print()
print("=" * 50)
print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)")
print("=" * 50)
print()
print("以下文件存在格式问题,建议手动修复:")
for line in result.stdout.strip().split("\n"):
print(f" {line}")
print()
print("修复方式:")
print(" 后端(Python): 运行 black + isort")
print(" 前端: 运行 prettier --write")
print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)")
print()
print("=" * 50)
# 以非0状态码退出,让CI继续报失败(因为问题没修)
sys.exit(1)
print()
print("变更文件:")
for line in result.stdout.strip().split("\n"):
@@ -342,7 +333,7 @@ def main():
# 提交修复
run("git add -A")
run('git commit -m "style: auto-format with black + isort + prettier"')
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
# 推送(head_branch已从ensure_git_repo获取)
print(f"\nPR来源分支: {head_branch}")
+109 -16
View File
@@ -10,6 +10,7 @@ import argparse
import json
import logging
import os
import re
import sys
from typing import Optional, Tuple
@@ -183,6 +184,37 @@ class GiteaClient:
return False
return True
def create_commit_status(
self, sha: str, state: str, context: str, description: str = "", target_url: str = ""
) -> bool:
"""
给指定 commit 打 status。
state: pending / success / failure / error / warning
Gitea API: POST /repos/{owner}/{repo}/statuses/{sha}
"""
url = self._api_url(f"statuses/{sha}")
logger.info(f"设置 commit status: sha={sha[:12]}..., state={state}, context={context}")
payload = {
"state": state,
"context": context,
"description": description[:200] if description else "",
}
if target_url:
payload["target_url"] = target_url
resp = self.session.post(
url,
data=json.dumps(payload),
timeout=GITEA_TIMEOUT,
)
if resp.status_code not in (200, 201):
logger.error(f"设置 status 失败: HTTP {resp.status_code} - {resp.text[:200]}")
return False
logger.info(f"Status 设置成功: {context} = {state}")
return True
def call_llm_openai(
prompt: str,
@@ -416,7 +448,22 @@ def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str:
```
## 审查要求
请从以下维度进行审查,重点关注严重问题
请从以下维度进行审查,重点关注**阻塞级问题**
### 问题分级标准
- **🔴 阻塞级(BLOCKER)**:必须修复,否则不允许合并。包括:
1. **明显逻辑bug**:条件判断错误、死循环、返回值错误、空指针/None引用未处理、边界条件遗漏导致功能异常
2. **安全漏洞**:SQL注入、XSS、命令注入、敏感信息明文存储/泄露、权限绕过、认证缺失
3. **语法错误**:代码存在语法层面的错误,无法运行
4. **数据损坏风险**:可能导致数据丢失、数据不一致、脏数据写入的问题
- **💡 建议级(SUGGESTION)**:不阻塞合并,仅供参考改进。包括:
1. 命名不规范、代码风格问题
2. 最佳实践建议、设计模式优化
3. 格式问题(缩进、空行、import顺序等)
4. 代码可读性改进、注释补充
5. 非关键路径的轻微性能优化建议
6. 重复代码、过长函数等代码质量问题
1. **逻辑正确性**:是否有明显的逻辑错误、边界条件遗漏、空指针/None引用风险
2. **异常处理**:异常捕获是否合理,是否有裸except,错误处理是否完善
@@ -426,20 +473,24 @@ def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str:
6. **安全问题**:是否有注入风险、敏感信息泄露、权限控制问题
## 输出格式
请使用以下格式输出,语言为中文:
请使用以下格式输出,语言为中文。**必须严格按照格式输出,尤其是【阻塞级判定】部分**
### 【阻塞级判定】
- 是否存在阻塞级问题:(是 / 否)
- 阻塞级问题数量:X 个
### 📊 审查概览
- 整体评价:(通过 / 有建议 / 需修改)
- 严重问题数量:X 个
- 一般建议数量:X 个
- 建议级问题数量:X 个
### ❌ 需修改的问题(严重
(如果没有严重问题,写""
### 🔴 阻塞级问题(必须修复
(如果没有阻塞级问题,写""
1. **[文件: 行号] 问题标题**
- 问题类型:(逻辑bug / 安全漏洞 / 语法错误 / 数据损坏风险)
- 问题描述:...
- 修改建议:...
### 💡 改进建议(一般
### 💡 改进建议(不阻塞合并
(如果没有建议,写""
1. **[文件: 行号] 建议标题**
- 具体内容:...
@@ -448,10 +499,46 @@ def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str:
(可选,列出值得肯定的地方)
请务必基于代码实际内容审查,不要编造不存在的问题。如果代码质量良好,直接给出通过结论即可。
**重要:【阻塞级判定】必须准确,只有确实存在严重问题时才写""。**
"""
return prompt
def parse_blocker_result(review_text: str) -> Tuple[bool, int]:
"""
从审查结果中解析是否存在阻塞级问题。
返回 (has_blocker, blocker_count)
"""
# 先找【阻塞级判定】部分的明确标记
pattern = r"【阻塞级判定】[\s\S]*?是否存在阻塞级问题[:]\s*(是|否)"
match = re.search(pattern, review_text)
if match:
has_blocker = match.group(1) == ""
else:
# fallback 1: 找"阻塞级问题数量"
count_pattern = r"阻塞级问题数量[:]\s*(\d+)"
count_match = re.search(count_pattern, review_text)
if count_match:
has_blocker = int(count_match.group(1)) > 0
else:
# fallback 2: 检查是否有"阻塞级问题"section且内容不是"无"
has_blocker = False
blocker_section = re.search(r"### 🔴 阻塞级问题[\s\S]*?(?=### |\Z)", review_text)
if blocker_section:
section_text = blocker_section.group(0)
# 如果有编号列表项,说明有问题
if re.search(r"\d+\.\s*\*\*", section_text):
has_blocker = True
# 提取数量
count_pattern = r"阻塞级问题数量[:]\s*(\d+)"
count_match = re.search(count_pattern, review_text)
blocker_count = int(count_match.group(1)) if count_match else (1 if has_blocker else 0)
logger.info(f"阻塞级问题解析: 存在={has_blocker}, 数量={blocker_count}")
return has_blocker, blocker_count
def call_llm_for_review(
diff_text: str,
pr_number: int,
@@ -628,7 +715,7 @@ def main():
if not review_result:
logger.error("LLM 审查失败")
sys.exit(1)
sys.exit(0) # fail-open: LLM调用失败不阻塞合并
# 7. 加上审查时间和标识(便于识别是自动审查)
from datetime import datetime
@@ -669,18 +756,24 @@ def main():
logger.error("评论发布失败")
sys.exit(1)
# 10. 判断是否有严重问题(可选阻断)
# 目前只做建议,不阻断合并,始终返回 0
has_critical = "问题" in review_result and ("" in review_result or "需修改" in review_result)
if has_critical:
logger.warning("检测到需修改的问题,但当前配置为仅建议,不阻断合并")
# 10. 解析阻塞级问题,用退出码决定 job 状态
# 有阻塞级问题 → exit 1 → job失败 → Gitea自动打failure status → 门禁拦截
# 无阻塞级问题 → exit 0 → job成功 → Gitea自动打success status
# LLM调用失败等异常 → exit 0 → fail-open,不阻塞正常开发
has_blocker, blocker_count = parse_blocker_result(review_result)
logger.info("代码审查完成")
sys.exit(0)
if has_blocker:
logger.error(f"检测到 {blocker_count} 个阻塞级问题,审查不通过")
logger.info("代码审查完成(失败)")
sys.exit(1)
else:
logger.info("无阻塞级问题,审查通过")
logger.info("代码审查完成(通过)")
sys.exit(0)
except Exception as e:
logger.exception(f"审查脚本发生未预期的异常: {e}")
sys.exit(1)
sys.exit(0) # fail-open: 异常不阻塞正常开发
if __name__ == "__main__":
+4 -4
View File
@@ -7,6 +7,10 @@ from dataclasses import dataclass
import pytest
from packages.domain.asset_scoring import (
WEIGHT_BITRATE,
WEIGHT_DURATION,
WEIGHT_QUALITY,
WEIGHT_RESOLUTION,
AssetScoreDetail,
SmartSelectResult,
_bucket_by_duration,
@@ -17,10 +21,6 @@ from packages.domain.asset_scoring import (
score_bitrate,
score_duration,
score_resolution,
WEIGHT_QUALITY,
WEIGHT_RESOLUTION,
WEIGHT_DURATION,
WEIGHT_BITRATE,
)
# ── 常量与权重 ──────────────────────────────────────────────────────────────
+658
View File
@@ -0,0 +1,658 @@
"""BGM 混音纯逻辑单元测试."""
from __future__ import annotations
import pytest
from video_processing.bgm_mixer_pure import (
BGMPureConfig,
build_bgm_filter_chain,
build_sidechain_mix_filter,
build_simple_mix_filter,
calculate_fade_out_start,
calculate_loop_count,
calculate_sidechain_ratio,
estimate_bgm_processing_duration,
normalize_bgm_config,
should_loop_bgm,
validate_bgm_config,
)
# ─────────────────────────────────────────────────────────────────────────────
# should_loop_bgm 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestShouldLoopBGM:
"""BGM 循环判断测试."""
def test_need_loop_when_much_shorter(self):
"""BGM 远短于目标时长,需要循环."""
assert should_loop_bgm(10, 100, True) is True
def test_no_loop_when_long_enough(self):
"""BGM 够长,不需要循环."""
assert should_loop_bgm(100, 100, True) is False
def test_no_loop_when_just_slightly_shorter(self):
"""BGM 只差一点点(>90%),不循环."""
assert should_loop_bgm(95, 100, True) is False
def test_threshold_90_percent(self):
"""刚好 90% 阈值,不循环(<90% 才循环)."""
assert should_loop_bgm(90, 100, True) is False
def test_just_below_threshold(self):
"""略低于 90%,需要循环."""
assert should_loop_bgm(89, 100, True) is True
def test_loop_disabled(self):
"""禁用循环,即使 BGM 很短也不循环."""
assert should_loop_bgm(10, 100, False) is False
def test_zero_bgm_duration(self):
"""BGM 时长为 0,不循环."""
assert should_loop_bgm(0, 100, True) is False
def test_negative_bgm_duration(self):
"""BGM 时长为负,不循环."""
assert should_loop_bgm(-5, 100, True) is False
def test_zero_target_duration(self):
"""目标时长为 0,不循环."""
assert should_loop_bgm(10, 0, True) is False
def test_negative_target_duration(self):
"""目标时长为负,不循环."""
assert should_loop_bgm(10, -10, True) is False
# ─────────────────────────────────────────────────────────────────────────────
# calculate_loop_count 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateLoopCount:
"""循环次数计算测试."""
def test_exact_multiple(self):
"""刚好整数倍."""
# 100/10 = 10, +2 = 12
assert calculate_loop_count(10, 100) == 12
def test_not_exact_multiple(self):
"""不是整数倍."""
# 100/30 = 3, +2 = 5
assert calculate_loop_count(30, 100) == 5
def test_bgm_longer_than_target(self):
"""BGM 比目标长,至少 1 次."""
assert calculate_loop_count(200, 100) == 1
def test_zero_bgm_duration(self):
"""BGM 时长为 0,返回 1."""
assert calculate_loop_count(0, 100) == 1
def test_negative_bgm_duration(self):
"""BGM 时长为负,返回 1."""
assert calculate_loop_count(-5, 100) == 1
def test_zero_target_duration(self):
"""目标时长为 0,返回 1."""
assert calculate_loop_count(10, 0) == 1
def test_negative_target_duration(self):
"""目标时长为负,返回 1."""
assert calculate_loop_count(10, -10) == 1
def test_very_short_bgm(self):
"""非常短的 BGM,循环次数多."""
# 100/1 = 100, +2 = 102
assert calculate_loop_count(1, 100) == 102
# ─────────────────────────────────────────────────────────────────────────────
# build_bgm_filter_chain 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildBGMFilterChain:
"""BGM 预处理滤镜链构建测试."""
def test_basic_volume_only(self):
"""只有音量调节."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=0.5,
)
assert "volume=0.500" in result
assert "aloop" not in result
assert "afade=t=in" not in result
assert "afade=t=out" not in result
assert "atrim=0:100.000" in result
assert "asetpts=N/SR/TB" in result
def test_with_loop(self):
"""需要循环的情况."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.3,
loop_enabled=True,
)
assert "aloop=loop=" in result
assert "volume=0.300" in result
def test_no_loop_when_disabled(self):
"""禁用循环,即使 BGM 短也不循环."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.3,
loop_enabled=False,
)
assert "aloop" not in result
def test_fade_in_only(self):
"""只有淡入."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_in=2.5,
)
assert "afade=t=in:st=0:d=2.500" in result
assert "afade=t=out" not in result
assert "volume=" not in result # volume=1.0 不加
def test_fade_out_only(self):
"""只有淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_out=3.0,
)
assert "afade=t=out:st=97.000:d=3.000" in result
assert "afade=t=in" not in result
def test_fade_in_and_out(self):
"""淡入+淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_in=1.5,
fade_out=2.0,
)
assert "afade=t=in:st=0:d=1.500" in result
assert "afade=t=out:st=98.000:d=2.000" in result
def test_volume_1_0_skipped(self):
"""音量为 1.0 时不添加 volume 滤镜."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
)
assert "volume=" not in result
def test_volume_0(self):
"""音量为 0."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=0.0,
)
assert "volume=0.000" in result
def test_volume_clamped_high(self):
"""音量超过 1.0 被钳制."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.5,
)
assert "volume=1.000" not in result # 1.0不加
# 钳制到1.0后和1.0一样,不加volume滤镜
# 但因为abs(1.0 - 1.0) < 0.001,所以不添加
assert "volume=" not in result
def test_volume_clamped_low(self):
"""音量为负被钳制到 0."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=-0.5,
)
assert "volume=0.000" in result
def test_fade_out_longer_than_duration(self):
"""淡出时长超过总时长,不加淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=10,
volume=1.0,
fade_out=20.0,
)
assert "afade=t=out" not in result
def test_fade_out_equal_to_duration(self):
"""淡出时长等于总时长,不加淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=10,
volume=1.0,
fade_out=10.0,
)
assert "afade=t=out" not in result
def test_zero_target_duration_fallback(self):
"""目标时长为 0,兜底 5 秒."""
result = build_bgm_filter_chain(
bgm_duration=3,
target_duration=0,
volume=0.5,
)
assert "atrim=0:5.000" in result
def test_negative_target_duration_fallback(self):
"""目标时长为负,兜底 5 秒."""
result = build_bgm_filter_chain(
bgm_duration=3,
target_duration=-5,
volume=0.5,
)
assert "atrim=0:5.000" in result
def test_full_chain_with_all_effects(self):
"""完整滤镜链:循环+音量+淡入淡出+截断+重置."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.4,
fade_in=1.0,
fade_out=2.0,
loop_enabled=True,
)
parts = result.split(",")
# 顺序:aloop -> volume -> afade in -> afade out -> atrim -> asetpts
assert len(parts) >= 6
assert "aloop" in parts[0]
assert "volume" in parts[1]
assert "afade=t=in" in parts[2]
assert "afade=t=out" in parts[3]
assert "atrim" in parts[4]
assert "asetpts" in parts[5]
# ─────────────────────────────────────────────────────────────────────────────
# calculate_sidechain_ratio 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateSidechainRatio:
"""Sidechain 压缩比计算测试."""
def test_default_ratio_0_3(self):
"""默认 0.3."""
# 1 / (1 - 0.3) = 1.428... 但下限是 2.0
assert calculate_sidechain_ratio(0.3) == pytest.approx(2.0, rel=0.01)
def test_ratio_0_5(self):
"""比例 0.5."""
# 1 / (1 - 0.5) = 2.0
assert calculate_sidechain_ratio(0.5) == pytest.approx(2.0, rel=0.01)
def test_ratio_0_8(self):
"""比例 0.8."""
# 1 / (1 - 0.8) = 5.0
assert calculate_sidechain_ratio(0.8) == pytest.approx(5.0, rel=0.01)
def test_ratio_0_9(self):
"""比例 0.9."""
# 1 / (1 - 0.9) = 10.0
assert calculate_sidechain_ratio(0.9) == pytest.approx(10.0, rel=0.01)
def test_ratio_0(self):
"""比例 0,返回下限 2.0."""
assert calculate_sidechain_ratio(0.0) == 2.0
def test_ratio_negative(self):
"""比例为负,返回下限 2.0."""
assert calculate_sidechain_ratio(-0.5) == 2.0
def test_ratio_1_0(self):
"""比例 1.0,返回上限 10.0."""
assert calculate_sidechain_ratio(1.0) == 10.0
def test_ratio_greater_than_1(self):
"""比例超过 1.0,返回上限 10.0."""
assert calculate_sidechain_ratio(2.0) == 10.0
# ─────────────────────────────────────────────────────────────────────────────
# build_simple_mix_filter 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildSimpleMixFilter:
"""普通混音滤镜构建测试."""
def test_contains_amix(self):
"""包含 amix."""
result = build_simple_mix_filter()
assert "amix=inputs=2" in result
def test_contains_volume_compensation(self):
"""包含 volume=2 补偿."""
result = build_simple_mix_filter()
assert "volume=2" in result
def test_output_label(self):
"""输出标签为 [final]."""
result = build_simple_mix_filter()
assert "[final]" in result
def test_duration_first(self):
"""duration=first,以主音频时长为准."""
result = build_simple_mix_filter()
assert "duration=first" in result
# ─────────────────────────────────────────────────────────────────────────────
# build_sidechain_mix_filter 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildSidechainMixFilter:
"""Sidechain 混音滤镜构建测试."""
def test_contains_sidechaincompress(self):
"""包含 sidechaincompress."""
result = build_sidechain_mix_filter()
assert "sidechaincompress=" in result
def test_threshold_param(self):
"""threshold 参数正确."""
result = build_sidechain_mix_filter(threshold=-30.0)
assert "threshold=-30.0dB" in result
def test_attack_param(self):
"""attack 参数正确."""
result = build_sidechain_mix_filter(attack=0.05)
assert "attack=0.050" in result
def test_release_param(self):
"""release 参数正确."""
result = build_sidechain_mix_filter(release=0.8)
assert "release=0.800" in result
def test_knee_param(self):
"""knee=6 参数."""
result = build_sidechain_mix_filter()
assert "knee=6" in result
def test_contains_amix(self):
"""包含 amix 混音."""
result = build_sidechain_mix_filter()
assert "amix=inputs=2" in result
def test_volume_compensation(self):
"""volume=1.5 轻微补偿."""
result = build_sidechain_mix_filter()
assert "volume=1.5" in result
def test_bgmc_comp_label(self):
"""包含 [bgm_comp] 中间标签."""
result = build_sidechain_mix_filter()
assert "[bgm_comp]" in result
# ─────────────────────────────────────────────────────────────────────────────
# normalize_bgm_config 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestNormalizeBGMConfig:
"""配置规范化测试."""
def test_empty_dict_defaults(self):
"""空字典返回默认值."""
result = normalize_bgm_config({})
assert result["volume"] == 0.3
assert result["fade_in"] == 0.0
assert result["fade_out"] == 0.0
assert result["loop_enabled"] is True
assert result["sidechain_enabled"] is False
assert result["sidechain_ratio"] == 0.3
def test_volume_clamped(self):
"""音量钳制."""
result = normalize_bgm_config({"volume": 1.5})
assert result["volume"] == 1.0
result2 = normalize_bgm_config({"volume": -0.5})
assert result2["volume"] == 0.0
def test_fade_in_negative(self):
"""淡入为负钳制到 0."""
result = normalize_bgm_config({"fade_in": -1})
assert result["fade_in"] == 0.0
def test_fade_out_negative(self):
"""淡出为负钳制到 0."""
result = normalize_bgm_config({"fade_out": -1})
assert result["fade_out"] == 0.0
def test_sidechain_ratio_clamped(self):
"""sidechain_ratio 钳制."""
result = normalize_bgm_config({"sidechain_ratio": 1.5})
assert result["sidechain_ratio"] == 1.0
result2 = normalize_bgm_config({"sidechain_ratio": -0.1})
assert result2["sidechain_ratio"] == 0.0
def test_sidechain_attack_min(self):
"""attack 最小值 0.001."""
result = normalize_bgm_config({"sidechain_attack": 0})
assert result["sidechain_attack"] == 0.001
def test_sidechain_release_min(self):
"""release 最小值 0.01."""
result = normalize_bgm_config({"sidechain_release": 0})
assert result["sidechain_release"] == 0.01
def test_string_values_converted(self):
"""字符串数值被转换."""
result = normalize_bgm_config(
{
"volume": "0.5",
"fade_in": "2.0",
}
)
assert result["volume"] == 0.5
assert result["fade_in"] == 2.0
def test_loop_enabled_truthy(self):
"""loop_enabled 真值转换."""
result = normalize_bgm_config({"loop_enabled": 1})
assert result["loop_enabled"] is True
result2 = normalize_bgm_config({"loop_enabled": 0})
assert result2["loop_enabled"] is False
def test_preserves_unknown_keys(self):
"""未知 key 不保留."""
result = normalize_bgm_config({"unknown_key": "value", "volume": 0.5})
assert "unknown_key" not in result
assert result["volume"] == 0.5
# ─────────────────────────────────────────────────────────────────────────────
# validate_bgm_config 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateBGMConfig:
"""配置验证测试."""
def test_valid_config(self):
"""合法配置."""
ok, errors = validate_bgm_config(
{
"volume": 0.5,
"fade_in": 1.0,
"fade_out": 2.0,
"sidechain_ratio": 0.3,
}
)
assert ok is True
assert len(errors) == 0
def test_volume_not_number(self):
"""volume 不是数字."""
ok, errors = validate_bgm_config({"volume": "high"})
assert ok is False
assert any("volume" in e for e in errors)
def test_volume_out_of_range(self):
"""volume 超出范围."""
ok, errors = validate_bgm_config({"volume": 1.5})
assert ok is False
assert any("volume" in e for e in errors)
def test_fade_in_negative(self):
"""fade_in 为负."""
ok, errors = validate_bgm_config({"fade_in": -1})
assert ok is False
assert any("fade_in" in e for e in errors)
def test_fade_out_negative(self):
"""fade_out 为负."""
ok, errors = validate_bgm_config({"fade_out": -1})
assert ok is False
assert any("fade_out" in e for e in errors)
def test_sidechain_ratio_out_of_range(self):
"""sidechain_ratio 超出范围."""
ok, errors = validate_bgm_config({"sidechain_ratio": 2.0})
assert ok is False
assert any("sidechain_ratio" in e for e in errors)
def test_multiple_errors(self):
"""多个错误同时报告."""
ok, errors = validate_bgm_config(
{
"volume": 2.0,
"fade_in": -1,
"sidechain_ratio": -0.5,
}
)
assert ok is False
assert len(errors) >= 3
def test_empty_config_valid(self):
"""空配置(全用默认值)视为合法."""
ok, errors = validate_bgm_config({})
assert ok is True
assert len(errors) == 0
# ─────────────────────────────────────────────────────────────────────────────
# calculate_fade_out_start 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateFadeOutStart:
"""淡出开始时间计算测试."""
def test_normal_case(self):
"""正常情况."""
assert calculate_fade_out_start(100, 3) == pytest.approx(97.0)
def test_zero_fade_out(self):
"""淡出时长为 0,返回 None."""
assert calculate_fade_out_start(100, 0) is None
def test_negative_fade_out(self):
"""淡出时长为负,返回 None."""
assert calculate_fade_out_start(100, -1) is None
def test_zero_duration(self):
"""总时长为 0,返回 None."""
assert calculate_fade_out_start(0, 3) is None
def test_fade_out_longer_than_duration(self):
"""淡出超过总时长,返回 None."""
assert calculate_fade_out_start(10, 20) is None
def test_fade_out_equal_to_duration(self):
"""淡出等于总时长,返回 None."""
assert calculate_fade_out_start(10, 10) is None
# ─────────────────────────────────────────────────────────────────────────────
# estimate_bgm_processing_duration 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEstimateBGMProcessingDuration:
"""BGM 处理时长估算测试."""
def test_normal_case_with_loop(self):
"""正常循环情况,输出目标时长."""
assert estimate_bgm_processing_duration(10, 100, True) == 100
def test_bgm_longer_no_loop(self):
"""BGM 够长,不循环,截断到目标时长."""
assert estimate_bgm_processing_duration(200, 100, False) == 100
def test_bgm_shorter_no_loop(self):
"""BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断)."""
assert estimate_bgm_processing_duration(10, 100, False) == 100
def test_zero_target(self):
"""目标时长为 0,兜底 5 秒."""
assert estimate_bgm_processing_duration(10, 0, True) == 5.0
def test_negative_target(self):
"""目标时长为负,兜底 5 秒."""
assert estimate_bgm_processing_duration(10, -5, True) == 5.0
# ─────────────────────────────────────────────────────────────────────────────
# BGMPureConfig 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBGMPureConfig:
"""BGMPureConfig 数据类测试."""
def test_default_values(self):
"""默认值正确."""
config = BGMPureConfig()
assert config.volume == 0.3
assert config.fade_in == 0.0
assert config.fade_out == 0.0
assert config.loop_enabled is True
assert config.sidechain_enabled is False
assert config.sidechain_ratio == 0.3
assert config.sidechain_attack == 0.02
assert config.sidechain_release == 0.5
assert config.sidechain_threshold == -25.0
def test_custom_values(self):
"""自定义值."""
config = BGMPureConfig(
volume=0.7,
fade_in=1.0,
fade_out=2.0,
loop_enabled=False,
sidechain_enabled=True,
sidechain_ratio=0.5,
sidechain_attack=0.05,
sidechain_release=0.8,
sidechain_threshold=-30.0,
)
assert config.volume == 0.7
assert config.loop_enabled is False
assert config.sidechain_enabled is True
assert config.sidechain_threshold == -30.0
+534
View File
@@ -0,0 +1,534 @@
"""视频拼接引擎纯逻辑单元测试."""
from __future__ import annotations
import pytest
from video_processing.concat_engine_pure import (
build_concat_filter,
build_fps_filter,
build_scale_pad_filter,
build_single_segment_filter_chain,
calculate_scaled_size,
can_use_stream_copy,
count_valid_segments,
estimate_total_duration,
format_fps_filter,
generate_concat_file_list,
parse_fps,
resolve_output_params,
validate_concat_config,
validate_video_path,
)
# ─────────────────────────────────────────────────────────────────────────────
# 帧率解析测试
# ─────────────────────────────────────────────────────────────────────────────
class TestParseFps:
"""parse_fps 测试."""
def test_integer_fps(self):
"""整数帧率."""
assert parse_fps(30) == 30.0
def test_float_fps(self):
"""浮点帧率."""
assert parse_fps(29.97) == pytest.approx(29.97)
def test_string_integer(self):
"""字符串整数."""
assert parse_fps("30") == 30.0
def test_string_fraction(self):
"""分数字符串(30/1."""
assert parse_fps("30/1") == 30.0
def test_fraction_24000_1001(self):
"""23.976 帧率."""
result = parse_fps("24000/1001")
assert result == pytest.approx(23.976, rel=0.01)
def test_none_input(self):
"""None 输入返回默认值."""
assert parse_fps(None) == 30.0
def test_empty_string(self):
"""空字符串返回默认值."""
assert parse_fps("") == 30.0
def test_invalid_string(self):
"""无效字符串."""
assert parse_fps("abc") == 30.0
def test_zero_denominator(self):
"""分母为 0."""
assert parse_fps("30/0") == 30.0
def test_negative_fps(self):
"""负帧率."""
assert parse_fps(-30) == -30.0
class TestFormatFpsFilter:
"""format_fps_filter 测试."""
def test_integer_fps(self):
"""整数帧率."""
assert format_fps_filter(30.0) == "fps=30"
def test_float_fps(self):
"""浮点帧率."""
result = format_fps_filter(29.97)
assert result.startswith("fps=")
assert "29.97" in result
def test_near_integer(self):
"""接近整数."""
assert format_fps_filter(30.0001) == "fps=30"
# ─────────────────────────────────────────────────────────────────────────────
# 输出参数计算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestResolveOutputParams:
"""resolve_output_params 测试."""
def test_all_specified(self):
"""全部显式指定."""
w, h, fps = resolve_output_params(1920, 1080, 60.0)
assert w == 1920
assert h == 1080
assert fps == 60.0
def test_no_specified_use_defaults(self):
"""全部未指定,用默认值."""
w, h, fps = resolve_output_params(0, 0, 0)
assert w == 1080
assert h == 1920
assert fps == 30.0
def test_use_first_video_info(self):
"""用第一段视频信息."""
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
w, h, fps = resolve_output_params(0, 0, 0, info)
assert w == 1280
assert h == 720
assert fps == 24.0
def test_partial_specified(self):
"""部分指定,未指定的用探测值."""
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
w, h, fps = resolve_output_params(1920, 0, 0, info)
assert w == 1920 # 指定的
assert h == 720 # 探测的
assert fps == 24.0
def test_zero_size_clamped(self):
"""零尺寸被钳制."""
w, h, fps = resolve_output_params(0, 0, 0, {})
assert w >= 1
assert h >= 1
assert fps >= 1.0
def test_custom_defaults(self):
"""自定义默认值."""
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
assert w == 640
assert h == 480
assert fps == 25.0
class TestCalculateScaledSize:
"""calculate_scaled_size 测试."""
def test_same_ratio(self):
"""比例相同."""
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080)
assert sw == 1920
assert sh == 1080
assert ox == 0
assert oy == 0
def test_wider_source(self):
"""源更宽,上下填黑边."""
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
assert sw == 1080 # 以宽度为准
assert sh < 1920 # 高度按比例
assert ox == 0
assert oy > 0 # 垂直居中
def test_taller_source(self):
"""源更高,左右填黑边."""
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
assert sh == 1080 # 以高度为准
assert sw < 1920 # 宽度按比例
assert ox > 0 # 水平居中
assert oy == 0
def test_zero_source(self):
"""零尺寸源."""
sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100)
assert sw == 100
assert sh == 100
def test_scale_down(self):
"""缩小."""
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360)
assert sw == 640
assert sh == 360
assert ox == 0
assert oy == 0
def test_scale_up(self):
"""放大."""
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
assert sw == 1920
assert sh == 1080
# ─────────────────────────────────────────────────────────────────────────────
# stream copy 判断测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCanUseStreamCopy:
"""can_use_stream_copy 测试."""
def test_identical_segments(self):
"""所有段参数相同,可以 stream copy."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
def test_force_reencode(self):
"""强制重编码."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False
def test_different_codec(self):
"""编码不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_different_resolution(self):
"""分辨率不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_different_fps(self):
"""帧率不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_target_differs(self):
"""目标参数与源不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
def test_empty_segments(self):
"""空列表."""
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
def test_single_segment(self):
"""单段."""
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
# ─────────────────────────────────────────────────────────────────────────────
# 文件列表生成测试
# ─────────────────────────────────────────────────────────────────────────────
class TestGenerateConcatFileList:
"""generate_concat_file_list 测试."""
def test_single_file(self):
"""单个文件."""
result = generate_concat_file_list(["/a.mp4"])
assert "file '/a.mp4'" in result
assert result.endswith("\n")
def test_multiple_files(self):
"""多个文件."""
result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"])
lines = result.strip().split("\n")
assert len(lines) == 3
assert lines[0] == "file '/a.mp4'"
assert lines[1] == "file '/b.mp4'"
assert lines[2] == "file '/c.mp4'"
def test_empty_list(self):
"""空列表."""
result = generate_concat_file_list([])
assert result == "\n"
def test_path_with_single_quote(self):
"""路径包含单引号(转义)."""
result = generate_concat_file_list(["/path/to/file's.mp4"])
# 单引号应该被转义
assert "'\\''" in result or file
assert "file '" in result
def test_path_with_spaces(self):
"""路径包含空格."""
result = generate_concat_file_list(["/path/to/my video.mp4"])
assert "my video" in result
# ─────────────────────────────────────────────────────────────────────────────
# 滤镜构建测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildScalePadFilter:
"""scale+pad 滤镜测试."""
def test_contains_scale(self):
"""包含 scale."""
result = build_scale_pad_filter(1920, 1080)
assert "scale=" in result
def test_contains_pad(self):
"""包含 pad."""
result = build_scale_pad_filter(1920, 1080)
assert "pad=" in result
assert "1920:1080" in result
def test_force_original_aspect_ratio(self):
"""保持宽高比."""
result = build_scale_pad_filter(1920, 1080)
assert "force_original_aspect_ratio=decrease" in result
def test_black_padding(self):
"""黑边填充."""
result = build_scale_pad_filter(1920, 1080)
assert ":black" in result
class TestBuildFpsFilter:
"""fps 滤镜测试."""
def test_integer_fps(self):
"""整数帧率."""
assert build_fps_filter(30.0) == "fps=30"
def test_float_fps(self):
"""浮点帧率."""
result = build_fps_filter(29.97)
assert result.startswith("fps=")
class TestBuildConcatFilter:
"""concat 滤镜测试."""
def test_two_inputs_with_audio(self):
"""两路输入,有音频."""
result = build_concat_filter(2, has_audio=True)
assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result
assert "[concat_v][concat_a]" in result
def test_three_inputs_video_only(self):
"""三路输入,无音频."""
result = build_concat_filter(3, has_audio=False)
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result
assert "[concat_v]" in result
def test_single_input(self):
"""单路输入."""
result = build_concat_filter(1, has_audio=True)
assert "[0:v][0:a]concat=n=1:v=1:a=1" in result
def test_zero_inputs(self):
"""零输入."""
assert build_concat_filter(0) == ""
class TestBuildSingleSegmentFilterChain:
"""单段滤镜链测试."""
def test_with_audio(self):
"""有音频."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
assert "scale=" in result
assert "fps=" in result
assert "setpts=PTS-STARTPTS" in result
assert "asetpts=PTS-STARTPTS" in result
assert "[v0]" in result
assert "[a0]" in result
def test_video_only(self):
"""无音频."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False)
assert "scale=" in result
assert "setpts=" in result
assert "asetpts" not in result
assert "[v1]" in result
def test_segment_index_in_labels(self):
"""段索引在标签中."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 5)
assert "[5:v]" in result
assert "[v5]" in result
# ─────────────────────────────────────────────────────────────────────────────
# 配置验证测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateConcatConfig:
"""配置验证测试."""
def test_valid_config(self):
"""合法配置."""
config = {
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
"output_width": 1920,
"output_height": 1080,
"output_fps": 30,
}
ok, errors = validate_concat_config(config)
assert ok is True
assert len(errors) == 0
def test_empty_segments(self):
"""空段列表."""
ok, errors = validate_concat_config({"segments": []})
assert ok is False
assert any("至少需要" in e or "视频段" in e for e in errors)
def test_missing_video_path(self):
"""缺少 video_path."""
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
ok, errors = validate_concat_config(config)
assert ok is False
assert any("video_path" in e for e in errors)
def test_negative_width(self):
"""负宽度."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
ok, errors = validate_concat_config(config)
assert ok is False
assert any("output_width" in e for e in errors)
def test_negative_height(self):
"""负高度."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
ok, errors = validate_concat_config(config)
assert ok is False
assert any("output_height" in e for e in errors)
def test_negative_fps(self):
"""负帧率."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
ok, errors = validate_concat_config(config)
assert ok is False
assert any("output_fps" in e for e in errors)
def test_zero_output_params_ok(self):
"""零输出参数合法(表示自动探测)."""
config = {"segments": [{"video_path": "/a.mp4"}]}
ok, errors = validate_concat_config(config)
assert ok is True
# ─────────────────────────────────────────────────────────────────────────────
# 路径验证测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateVideoPath:
"""视频路径验证测试."""
def test_empty_path(self):
"""空路径."""
ok, msg = validate_video_path("", "/work")
assert ok is False
assert "不能为空" in msg
def test_path_traversal(self):
"""路径遍历."""
ok, msg = validate_video_path("../etc/passwd", "/work")
assert ok is False
assert "回溯" in msg or ".." in msg
def test_valid_relative_path(self):
"""相对路径(不检查边界)."""
ok, msg = validate_video_path("video.mp4", "/work")
assert ok is True
def test_valid_absolute_path(self):
"""绝对路径在工作目录内."""
ok, msg = validate_video_path("/work/sub/video.mp4", "/work")
assert ok is True
def test_path_outside_work_dir(self):
"""路径在工作目录外."""
ok, msg = validate_video_path("/etc/passwd", "/work")
assert ok is False
assert "工作目录" in msg
# ─────────────────────────────────────────────────────────────────────────────
# 工具函数测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEstimateTotalDuration:
"""总时长估算测试."""
def test_multiple_segments(self):
"""多段视频."""
segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}]
assert estimate_total_duration(segs) == pytest.approx(35.5)
def test_empty_list(self):
"""空列表."""
assert estimate_total_duration([]) == 0.0
def test_invalid_duration_skipped(self):
"""无效时长跳过."""
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
assert estimate_total_duration(segs) == pytest.approx(30.0)
def test_missing_duration(self):
"""缺 duration 字段."""
segs = [{}, {"duration": 10}]
assert estimate_total_duration(segs) == pytest.approx(10.0)
class TestCountValidSegments:
"""有效段统计测试."""
def test_all_valid(self):
"""全部有效."""
segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}]
assert count_valid_segments(segs) == 2
def test_some_invalid(self):
"""部分无效."""
segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}]
assert count_valid_segments(segs) == 1
def test_empty_list(self):
"""空列表."""
assert count_valid_segments([]) == 0
+172 -70
View File
@@ -17,7 +17,6 @@ from packages.domain.entities import (
Project,
)
# ── AssetStatus 枚举兼容 ────────────────────────────────────────────────────
@@ -165,9 +164,7 @@ class TestAssetLibrary:
assert lib.total_size == 0
def test_create_name_stripped(self):
lib = AssetLibrary.create(
project_id="p1", name=" 我的素材库 ", kind=AssetLibraryKind.VOICE
)
lib = AssetLibrary.create(project_id="p1", name=" 我的素材库 ", kind=AssetLibraryKind.VOICE)
assert lib.name == "我的素材库"
def test_create_empty_name_raises(self):
@@ -209,66 +206,97 @@ class TestAssetCreate:
def test_create_name_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1",
project_id="p1",
library_id="l1",
name=" video.mp4 ",
storage_key="k1", mime_type="video/mp4",
storage_key="k1",
mime_type="video/mp4",
)
assert asset.name == "video.mp4"
def test_create_storage_key_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key=" key1 ", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key=" key1 ",
mime_type="video/mp4",
)
assert asset.storage_key == "key1"
def test_create_mime_type_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type=" video/mp4 ",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type=" video/mp4 ",
)
assert asset.mime_type == "video/mp4"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="素材名称不能为空"):
Asset.create(
project_id="p1", library_id="l1", name="",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="",
storage_key="k1",
mime_type="video/mp4",
)
def test_create_empty_storage_key_raises(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key=" ", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key=" ",
mime_type="video/mp4",
)
def test_create_empty_mime_type_raises(self):
with pytest.raises(ValueError, match="mime_type 不能为空"):
Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="",
)
def test_create_with_file_size(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", file_size=1024000,
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
file_size=1024000,
)
assert asset.file_size == 1024000
def test_create_with_duration(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", duration=30.5,
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
duration=30.5,
)
assert asset.duration == 30.5
def test_create_with_dimensions(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
width=1080, height=1920, fps=30.0, codec="h264",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
width=1080,
height=1920,
fps=30.0,
codec="h264",
)
assert asset.width == 1080
assert asset.height == 1920
@@ -278,37 +306,55 @@ class TestAssetCreate:
def test_create_with_metadata(self):
meta = {"bitrate": 5000, "codec": "h264"}
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", metadata=meta,
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
metadata=meta,
)
assert asset.metadata == meta
def test_create_metadata_none_defaults_empty(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", metadata=None,
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
metadata=None,
)
assert asset.metadata == {}
def test_create_thumbnail_url_none(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", thumbnail_url=None,
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
thumbnail_url=None,
)
assert asset.thumbnail_url is None
def test_create_uploaded_by_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
uploaded_by_user_id=" user1 ",
)
assert asset.uploaded_by_user_id == "user1"
def test_create_file_hash_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
file_hash=" abc123 ",
)
assert asset.file_hash == "abc123"
@@ -317,29 +363,41 @@ class TestAssetCreate:
class TestAssetFileType:
def test_video_mime(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
assert asset.file_type == "video"
def test_audio_mime(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="a.mp3",
storage_key="k1", mime_type="audio/mpeg",
project_id="p1",
library_id="l1",
name="a.mp3",
storage_key="k1",
mime_type="audio/mpeg",
)
assert asset.file_type == "audio"
def test_image_mime(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="i.jpg",
storage_key="k1", mime_type="image/jpeg",
project_id="p1",
library_id="l1",
name="i.jpg",
storage_key="k1",
mime_type="image/jpeg",
)
assert asset.file_type == "image"
def test_invalid_mime_returns_full(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="f.xxx",
storage_key="k1", mime_type="application/octet-stream",
project_id="p1",
library_id="l1",
name="f.xxx",
storage_key="k1",
mime_type="application/octet-stream",
)
assert asset.file_type == "application"
@@ -347,8 +405,11 @@ class TestAssetFileType:
class TestAssetTags:
def test_add_tag(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
assert "tag_1" in asset.tag_ids
@@ -356,8 +417,11 @@ class TestAssetTags:
def test_add_tag_dedup(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.add_tag("tag_1")
@@ -365,32 +429,44 @@ class TestAssetTags:
def test_add_tag_empty_raises(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag("")
def test_add_tag_whitespace_raises(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag(" ")
def test_add_tag_strips_whitespace(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
asset.add_tag(" tag_1 ")
assert asset.tag_ids == ["tag_1"]
def test_remove_tag(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.add_tag("tag_2")
@@ -399,8 +475,11 @@ class TestAssetTags:
def test_remove_nonexistent_tag_no_error(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.remove_tag("nonexistent") # 幂等,不报错
@@ -408,8 +487,11 @@ class TestAssetTags:
def test_remove_tag_strips_whitespace(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.remove_tag(" tag_1 ")
@@ -417,22 +499,32 @@ class TestAssetTags:
def test_add_tag_updates_updated_at(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
old_updated = asset.updated_at
import time; time.sleep(0.001)
import time
time.sleep(0.001)
asset.add_tag("tag_1")
assert asset.updated_at > old_updated
def test_remove_tag_updates_updated_at(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="v.mp4",
storage_key="k1",
mime_type="video/mp4",
)
asset.add_tag("tag_1")
old_updated = asset.updated_at
import time; time.sleep(0.001)
import time
time.sleep(0.001)
asset.remove_tag("tag_1")
assert asset.updated_at > old_updated
@@ -458,26 +550,34 @@ class TestIngestJob:
def test_create_with_file_hash(self):
job = IngestJob.create(
project_id="p1", library_id="l1",
storage_key="k1", file_hash="abc123",
project_id="p1",
library_id="l1",
storage_key="k1",
file_hash="abc123",
)
assert job.file_hash == "abc123"
def test_create_project_id_stripped(self):
job = IngestJob.create(
project_id=" p1 ", library_id="l1", storage_key="k1",
project_id=" p1 ",
library_id="l1",
storage_key="k1",
)
assert job.project_id == "p1"
def test_create_library_id_stripped(self):
job = IngestJob.create(
project_id="p1", library_id=" l1 ", storage_key="k1",
project_id="p1",
library_id=" l1 ",
storage_key="k1",
)
assert job.library_id == "l1"
def test_create_storage_key_stripped(self):
job = IngestJob.create(
project_id="p1", library_id="l1", storage_key=" k1 ",
project_id="p1",
library_id="l1",
storage_key=" k1 ",
)
assert job.storage_key == "k1"
@@ -499,8 +599,10 @@ class TestIngestJob:
def test_create_file_hash_stripped(self):
job = IngestJob.create(
project_id="p1", library_id="l1",
storage_key="k1", file_hash=" hash123 ",
project_id="p1",
library_id="l1",
storage_key="k1",
file_hash=" hash123 ",
)
assert job.file_hash == "hash123"
+7 -4
View File
@@ -5,13 +5,12 @@ from __future__ import annotations
import pytest
from packages.domain.job import (
TERMINAL_STATUSES,
Job,
JobStatus,
JobType,
TERMINAL_STATUSES,
)
# ── 枚举常量 ────────────────────────────────────────────────────────────────
@@ -249,7 +248,9 @@ class TestTransitionTo:
def test_transition_updates_updated_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
old_updated = job.updated_at
import time; time.sleep(0.001)
import time
time.sleep(0.001)
job.transition_to(JobStatus.RUNNING)
assert job.updated_at >= old_updated
@@ -359,7 +360,9 @@ class TestUpdateProgress:
def test_progress_updates_updated_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
old_updated = job.updated_at
import time; time.sleep(0.001)
import time
time.sleep(0.001)
job.update_progress(50.0)
assert job.updated_at > old_updated
+638
View File
@@ -0,0 +1,638 @@
"""多轨混音纯逻辑单元测试."""
from __future__ import annotations
import math
import pytest
from video_processing.multi_track_mixer_pure import (
build_amix_filter,
build_mix_filter_complex,
build_track_filter_chain,
calculate_amix_volume_compensation,
calculate_effective_range,
calculate_total_tracks,
count_track_types,
db_to_linear,
estimate_mix_duration,
filter_enabled_tracks,
is_track_visible,
linear_to_db,
normalize_volume,
sort_tracks_by_priority,
validate_audio_track,
validate_mix_config,
)
# ─────────────────────────────────────────────────────────────────────────────
# 时间计算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateEffectiveRange:
"""有效时间范围计算测试."""
def test_normal_track(self):
"""正常轨道."""
start, dur, trim = calculate_effective_range(5, 10, 30, 60)
assert start == 5.0
assert dur == 10.0
assert trim == 0.0
def test_track_longer_than_audio(self):
"""轨道时长超过音频长度."""
start, dur, trim = calculate_effective_range(0, 100, 30, 60)
assert start == 0.0
assert dur == 30.0 # 用音频全长
def test_zero_track_duration(self):
"""轨道时长为 0(用音频全长)."""
start, dur, trim = calculate_effective_range(0, 0, 30, 60)
assert start == 0.0
assert dur == 30.0
def test_negative_start_time(self):
"""负开始时间(从音频中间取)."""
start, dur, trim = calculate_effective_range(-5, 20, 30, 60)
assert start == 0.0
assert dur == 15.0 # 20 - 5 = 15
assert trim == 5.0
def test_track_after_target(self):
"""轨道完全在目标之后."""
start, dur, trim = calculate_effective_range(100, 10, 30, 60)
assert dur == 0.0
def test_track_before_zero(self):
"""轨道完全在 0 之前."""
start, dur, trim = calculate_effective_range(-50, 10, 30, 60)
assert dur == 0.0
def test_zero_audio_duration(self):
"""音频时长为 0."""
start, dur, trim = calculate_effective_range(0, 10, 0, 60)
assert dur == 0.0
def test_track_extends_beyond_target(self):
"""轨道超出目标时长."""
start, dur, trim = calculate_effective_range(50, 20, 30, 60)
assert start == 50.0
assert dur == 10.0 # 60 - 50 = 10
def test_full_target_duration(self):
"""轨道覆盖整个目标时长."""
start, dur, trim = calculate_effective_range(0, 0, 100, 60)
assert start == 0.0
assert dur == 60.0
class TestIsTrackVisible:
"""轨道可见性测试."""
def test_visible_track(self):
"""可见轨道."""
assert is_track_visible(5, 10, 30, 60) is True
def test_invisible_after_target(self):
"""目标之后不可见."""
assert is_track_visible(100, 10, 30, 60) is False
def test_invisible_zero_duration(self):
"""零时长不可见."""
assert is_track_visible(0, 0, 0, 60) is False
# ─────────────────────────────────────────────────────────────────────────────
# 滤镜链构建测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildTrackFilterChain:
"""单轨滤镜链构建测试."""
def test_basic_structure(self):
"""基本结构:截断+音量+淡入淡出+延迟+截断."""
result = build_track_filter_chain(
volume=0.5,
fade_in=1.0,
fade_out=1.0,
effective_start=5.0,
need_duration=10.0,
trim_start=0.0,
target_duration=60.0,
)
assert "atrim=0.000:10.000" in result
assert "volume=0.500" in result
assert "afade=t=in:st=0:d=1.000" in result
assert "afade=t=out" in result
assert "adelay=5000|5000" in result
assert "atrim=0:60.000" in result
def test_volume_1_0_skipped(self):
"""音量为 1.0 不添加 volume 滤镜."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=0,
effective_start=0,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "volume=" not in result
def test_no_fade_in(self):
"""无淡入."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=2.0,
effective_start=0,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "afade=t=in" not in result
assert "afade=t=out" in result
def test_no_delay(self):
"""无延迟(effective_start 很小)."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=0,
effective_start=0.001,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "adelay" not in result
def test_with_delay(self):
"""有延迟."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=0,
effective_start=2.5,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "adelay=2500|2500" in result
def test_fade_in_longer_than_duration(self):
"""淡入超过总时长,不添加淡入."""
result = build_track_filter_chain(
volume=1.0,
fade_in=20,
fade_out=0,
effective_start=0,
need_duration=10,
trim_start=0,
target_duration=60,
)
assert "afade=t=in" not in result
def test_fade_out_at_start(self):
"""淡出从 0 开始(很短的音频)."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=15,
effective_start=0,
need_duration=10,
trim_start=0,
target_duration=60,
)
# fade_out > need_duration,不添加
assert "afade=t=out" not in result
def test_trim_start_nonzero(self):
"""从音频中间开始截取."""
result = build_track_filter_chain(
volume=1.0,
fade_in=0,
fade_out=0,
effective_start=0,
need_duration=5,
trim_start=3.0,
target_duration=60,
)
assert "atrim=3.000:8.000" in result # 3.0 to 3.0+5.0
# ─────────────────────────────────────────────────────────────────────────────
# amix 滤镜测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildAmixFilter:
"""amix 滤镜构建测试."""
def test_two_inputs(self):
"""两路输入."""
result = build_amix_filter(2)
assert "amix=inputs=2" in result
assert "duration=longest" in result
def test_five_inputs(self):
"""五路输入."""
result = build_amix_filter(5)
assert "amix=inputs=5" in result
def test_zero_inputs(self):
"""零输入."""
assert build_amix_filter(0) == ""
def test_duration_shortest(self):
"""shortest 模式."""
result = build_amix_filter(3, "shortest")
assert "duration=shortest" in result
def test_invalid_duration_mode(self):
"""无效模式,默认 longest."""
result = build_amix_filter(3, "invalid")
assert "duration=longest" in result
class TestCalculateAmixVolumeCompensation:
"""音量补偿计算测试."""
def test_single_track(self):
"""单轨,无需补偿."""
assert calculate_amix_volume_compensation(1) == 1.0
def test_two_tracks(self):
"""两轨,补偿 2x."""
assert calculate_amix_volume_compensation(2) == 2.0
def test_five_tracks(self):
"""五轨,补偿 5x."""
assert calculate_amix_volume_compensation(5) == 5.0
def test_zero_tracks(self):
"""零轨,返回 1."""
assert calculate_amix_volume_compensation(0) == 1.0
class TestBuildMixFilterComplex:
"""完整混音滤镜测试."""
def test_with_main_and_two_tracks(self):
"""主音频 + 2 条轨道."""
result = build_mix_filter_complex(2, has_main=True)
assert "[0:a][1:a][2:a]" in result # 3 路输入
assert "amix=inputs=3" in result
assert "volume=3" in result # 3x 补偿
assert "[mixed]" in result
def test_no_main_three_tracks(self):
"""无主音频,3 条轨道."""
result = build_mix_filter_complex(3, has_main=False)
assert "[0:a][1:a][2:a]" in result
assert "amix=inputs=3" in result
assert "[mixed]" in result
def test_zero_tracks_no_main(self):
"""无轨道无主音频."""
assert build_mix_filter_complex(0, has_main=False) == ""
# ─────────────────────────────────────────────────────────────────────────────
# 音量计算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestNormalizeVolume:
"""音量规范化测试."""
def test_normal_volume(self):
"""正常音量."""
assert normalize_volume(0.5) == 0.5
def test_none_default(self):
"""None 默认 1.0."""
assert normalize_volume(None) == 1.0
def test_below_zero_clamped(self):
"""负值钳制到 0."""
assert normalize_volume(-5) == 0.0
def test_above_max_clamped(self):
"""超过上限钳制."""
assert normalize_volume(3.0) == 2.0
def test_string_input(self):
"""字符串输入."""
assert normalize_volume("0.5") == 0.5
def test_invalid_string(self):
"""无效字符串默认 1.0."""
assert normalize_volume("abc") == 1.0
class TestDbConversion:
"""dB 转换测试."""
def test_0_db_is_unity(self):
"""0 dB = 1.0."""
assert db_to_linear(0) == pytest.approx(1.0)
def test_negative_db(self):
"""负 dB < 1."""
assert db_to_linear(-6) == pytest.approx(0.5, rel=0.01)
def test_positive_db(self):
"""正 dB > 1."""
assert db_to_linear(6) == pytest.approx(2.0, rel=0.01)
def test_round_trip(self):
"""往返转换."""
original = 0.5
db = linear_to_db(original)
result = db_to_linear(db)
assert result == pytest.approx(original)
def test_zero_linear_is_negative_inf(self):
"""零线性值 = -inf dB."""
assert math.isinf(linear_to_db(0))
assert linear_to_db(0) < 0
# ─────────────────────────────────────────────────────────────────────────────
# 轨道排序与过滤测试
# ─────────────────────────────────────────────────────────────────────────────
class TestSortTracksByPriority:
"""轨道优先级排序测试."""
def test_sorted_by_priority(self):
"""按优先级排序."""
tracks = [
{"priority": 10, "name": "high"},
{"priority": 1, "name": "highest"},
{"priority": 100, "name": "low"},
]
result = sort_tracks_by_priority(tracks)
assert result[0]["name"] == "highest"
assert result[1]["name"] == "high"
assert result[2]["name"] == "low"
def test_default_priority_100(self):
"""默认优先级 100."""
tracks = [
{"priority": 50, "name": "mid"},
{"name": "default"},
]
result = sort_tracks_by_priority(tracks)
assert result[0]["name"] == "mid"
assert result[1]["name"] == "default"
def test_same_preserves_order(self):
"""同优先级保持顺序."""
tracks = [
{"priority": 10, "name": "first"},
{"priority": 10, "name": "second"},
]
result = sort_tracks_by_priority(tracks)
assert result[0]["name"] == "first"
assert result[1]["name"] == "second"
def test_empty_list(self):
"""空列表."""
assert sort_tracks_by_priority([]) == []
class TestFilterEnabledTracks:
"""启用轨道过滤测试."""
def test_all_enabled(self):
"""全部启用."""
tracks = [{"enabled": True}, {"enabled": True}]
assert len(filter_enabled_tracks(tracks)) == 2
def test_mixed(self):
"""混合."""
tracks = [
{"enabled": True, "name": "a"},
{"enabled": False, "name": "b"},
]
result = filter_enabled_tracks(tracks)
assert len(result) == 1
assert result[0]["name"] == "a"
def test_default_enabled(self):
"""默认启用."""
tracks = [{"name": "a"}]
assert len(filter_enabled_tracks(tracks)) == 1
def test_empty_list(self):
"""空列表."""
assert filter_enabled_tracks([]) == []
class TestCountTrackTypes:
"""轨道类型统计测试."""
def test_mixed_types(self):
"""混合类型."""
tracks = [
{"track_type": "bgm"},
{"track_type": "voiceover"},
{"track_type": "bgm"},
{"track_type": "sfx"},
]
counts = count_track_types(tracks)
assert counts["bgm"] == 2
assert counts["voiceover"] == 1
assert counts["sfx"] == 1
def test_default_type(self):
"""默认类型."""
tracks = [{}]
counts = count_track_types(tracks)
assert counts["unknown"] == 1
def test_empty_list(self):
"""空列表."""
assert count_track_types([]) == {}
# ─────────────────────────────────────────────────────────────────────────────
# 配置验证测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateAudioTrack:
"""单轨验证测试."""
def test_valid_track(self):
"""合法轨道."""
ok, errors = validate_audio_track(
{
"audio_path": "/audio.mp3",
"volume": 0.8,
"fade_in": 1.0,
"fade_out": 2.0,
}
)
assert ok is True
assert len(errors) == 0
def test_missing_path(self):
"""缺路径."""
ok, errors = validate_audio_track({})
assert ok is False
assert any("audio_path" in e or "asset_id" in e for e in errors)
def test_negative_volume(self):
"""负音量."""
ok, errors = validate_audio_track(
{
"audio_path": "/a.mp3",
"volume": -1,
}
)
assert ok is False
assert any("volume" in e for e in errors)
def test_negative_fade_in(self):
"""负淡入."""
ok, errors = validate_audio_track(
{
"audio_path": "/a.mp3",
"fade_in": -1,
}
)
assert ok is False
assert any("fade_in" in e for e in errors)
def test_negative_fade_out(self):
"""负淡出."""
ok, errors = validate_audio_track(
{
"audio_path": "/a.mp3",
"fade_out": -1,
}
)
assert ok is False
assert any("fade_out" in e for e in errors)
def test_invalid_volume_type(self):
"""无效音量类型."""
ok, errors = validate_audio_track(
{
"audio_path": "/a.mp3",
"volume": "loud",
}
)
assert ok is False
assert any("volume" in e for e in errors)
def test_with_asset_id(self):
"""有 asset_id 无 audio_path 也合法."""
ok, errors = validate_audio_track({"asset_id": "123"})
assert ok is True
class TestValidateMixConfig:
"""混音配置验证测试."""
def test_valid_config(self):
"""合法配置."""
ok, errors = validate_mix_config(
{
"tracks": [
{"audio_path": "/a.mp3", "volume": 0.5},
{"audio_path": "/b.mp3", "volume": 0.8},
],
"target_duration": 60,
}
)
assert ok is True
def test_empty_tracks(self):
"""空轨道列表."""
ok, errors = validate_mix_config({"tracks": []})
assert ok is False
assert any("至少需要" in e for e in errors)
def test_invalid_track(self):
"""无效轨道."""
ok, errors = validate_mix_config(
{
"tracks": [
{"audio_path": "/a.mp3"},
{}, # 无效
],
}
)
assert ok is False
assert len(errors) >= 1
def test_negative_target_duration(self):
"""负目标时长."""
ok, errors = validate_mix_config(
{
"tracks": [{"audio_path": "/a.mp3"}],
"target_duration": -10,
}
)
assert ok is False
assert any("target_duration" in e for e in errors)
# ─────────────────────────────────────────────────────────────────────────────
# 工具函数测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateTotalTracks:
"""总轨道数计算测试."""
def test_with_main(self):
"""含主音频."""
assert calculate_total_tracks({"tracks": [1, 2, 3]}) == 4
def test_without_main(self):
"""不含主音频."""
assert (
calculate_total_tracks(
{
"tracks": [1, 2],
"has_main_audio": False,
}
)
== 2
)
def test_empty_tracks_with_main(self):
"""无轨道,只有主音频."""
assert calculate_total_tracks({"tracks": []}) == 1
class TestEstimateMixDuration:
"""混音时长估算测试."""
def test_multiple_tracks(self):
"""多轨道取最长结束时间."""
tracks = [
{"start_time": 0, "duration": 10},
{"start_time": 5, "duration": 20}, # 结束 25
{"start_time": 2, "duration": 5},
]
assert estimate_mix_duration(tracks) == pytest.approx(25.0)
def test_empty_list(self):
"""空列表."""
assert estimate_mix_duration([]) == 0.0
def test_zero_duration_tracks_ignored(self):
"""零时长轨道忽略."""
tracks = [
{"start_time": 0, "duration": 0},
{"start_time": 5, "duration": 10},
]
assert estimate_mix_duration(tracks) == pytest.approx(15.0)
+32 -15
View File
@@ -7,19 +7,18 @@ import math
import pytest
from packages.domain.quota import (
QuotaCheckResult,
QUOTA_TIERS,
QuotaChecker,
QuotaCheckResult,
QuotaDimension,
QuotaRegistry,
QuotaTier,
QuotaWarningLevel,
QUOTA_TIERS,
get_warning_level,
quota_checker,
quota_registry,
)
# ── 枚举与常量 ──────────────────────────────────────────────────────────────
@@ -134,48 +133,66 @@ class TestQuotaTiers:
class TestQuotaCheckResult:
def test_usage_percent_normal(self):
result = QuotaCheckResult(
allowed=True, dimension="storage_gb",
limit=100, used=50, remaining=50,
allowed=True,
dimension="storage_gb",
limit=100,
used=50,
remaining=50,
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 50.0
def test_usage_percent_zero_usage(self):
result = QuotaCheckResult(
allowed=True, dimension="storage_gb",
limit=100, used=0, remaining=100,
allowed=True,
dimension="storage_gb",
limit=100,
used=0,
remaining=100,
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 0.0
def test_usage_percent_exceeded_capped(self):
result = QuotaCheckResult(
allowed=False, dimension="storage_gb",
limit=100, used=150, remaining=0,
allowed=False,
dimension="storage_gb",
limit=100,
used=150,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert result.usage_percent == 100.0 # capped at 100
def test_usage_percent_zero_limit_with_usage(self):
result = QuotaCheckResult(
allowed=False, dimension="storage_gb",
limit=0, used=10, remaining=0,
allowed=False,
dimension="storage_gb",
limit=0,
used=10,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert result.usage_percent == 100.0
def test_usage_percent_zero_limit_no_usage(self):
result = QuotaCheckResult(
allowed=True, dimension="storage_gb",
limit=0, used=0, remaining=0,
allowed=True,
dimension="storage_gb",
limit=0,
used=0,
remaining=0,
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 0.0
def test_usage_percent_unlimited(self):
result = QuotaCheckResult(
allowed=True, dimension="max_templates",
limit=float("inf"), used=100, remaining=float("inf"),
allowed=True,
dimension="max_templates",
limit=float("inf"),
used=100,
remaining=float("inf"),
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 0.0
+86 -32
View File
@@ -18,7 +18,6 @@ from packages.domain.template_clip_config import (
TransitionEffect,
)
# ── EditingMode ────────────────────────────────────────────────────────────
@@ -191,13 +190,17 @@ class TestTemplateClipConfigCreate:
def test_clip_type_string(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type="intro", order=0,
template_id="t1",
clip_type="intro",
order=0,
)
assert clip.clip_type == ClipType.INTRO
def test_template_id_stripped(self):
clip = TemplateClipConfig.create(
template_id=" tmpl_1 ", clip_type=ClipType.MAIN, order=1,
template_id=" tmpl_1 ",
clip_type=ClipType.MAIN,
order=1,
)
assert clip.template_id == "tmpl_1"
@@ -212,50 +215,68 @@ class TestTemplateClipConfigCreate:
def test_negative_min_duration_raises(self):
with pytest.raises(ValueError, match="min_duration 不能为负数"):
TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=-1.0,
)
def test_negative_max_duration_raises(self):
with pytest.raises(ValueError, match="max_duration 不能为负数"):
TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
max_duration=-1.0,
)
def test_min_greater_than_max_raises(self):
with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"):
TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=10.0, max_duration=5.0,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=10.0,
max_duration=5.0,
)
def test_zero_min_and_max_ok(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=0, max_duration=0,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=0,
max_duration=0,
)
assert clip.min_duration == 0
assert clip.max_duration == 0
def test_min_zero_max_positive_ok(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=0, max_duration=10.0,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=0,
max_duration=10.0,
)
assert clip.max_duration == 10.0
def test_min_equals_max_ok(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=5.0, max_duration=5.0,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=5.0,
max_duration=5.0,
)
assert clip.min_duration == 5.0
assert clip.max_duration == 5.0
def test_with_text_template(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
text_template=" 欢迎收看 {channel} ",
)
# text_template 会 strip
@@ -264,21 +285,27 @@ class TestTemplateClipConfigCreate:
def test_with_material_requirements(self):
reqs = {"material_type": "video", "min_duration": 3}
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
material_requirements=reqs,
)
assert clip.material_requirements == reqs
def test_material_requirements_none_defaults_empty(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
material_requirements=None,
)
assert clip.material_requirements == {}
def test_transition_effect_string(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
transition_effect="fade",
)
assert clip.transition_effect == TransitionEffect.FADE
@@ -286,21 +313,27 @@ class TestTemplateClipConfigCreate:
def test_with_config(self):
config = {"speed": 1.5, "filter": "vibrance"}
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
config=config,
)
assert clip.config == config
def test_config_none_defaults_empty(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
config=None,
)
assert clip.config == {}
def test_created_at_and_updated_at(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
)
assert clip.created_at is not None
assert clip.updated_at is not None
@@ -317,61 +350,82 @@ class TestTemplateClipConfigCreate:
class TestTemplateClipConfigProperties:
def test_has_duration_range_false_both_zero(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
)
assert clip.has_duration_range is False
def test_has_duration_range_true_min_only(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=2.0,
)
assert clip.has_duration_range is True
def test_has_duration_range_true_max_only(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
max_duration=10.0,
)
assert clip.has_duration_range is True
def test_has_duration_range_true_both_set(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=2.0, max_duration=10.0,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=2.0,
max_duration=10.0,
)
assert clip.has_duration_range is True
def test_default_duration_zero(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
)
assert clip.default_duration == 0.0
def test_default_duration_min_only(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=3.0,
)
assert clip.default_duration == 3.0
def test_default_duration_max_only(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
max_duration=10.0,
)
assert clip.default_duration == 10.0
def test_default_duration_both_midpoint(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=2.0, max_duration=8.0,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=2.0,
max_duration=8.0,
)
assert clip.default_duration == 5.0
def test_default_duration_min_equals_max(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=5.0, max_duration=5.0,
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=5.0,
max_duration=5.0,
)
assert clip.default_duration == 5.0
+1 -1
View File
@@ -11,8 +11,8 @@ from packages.domain.speed_config import (
SpeedConfig,
adjust_duration,
build_audio_filter,
build_video_filter,
build_clip_speed_filter,
build_video_filter,
resolve_clip_speed,
)
+780
View File
@@ -0,0 +1,780 @@
"""贴纸引擎纯逻辑单元测试."""
from __future__ import annotations
import pytest
from video_processing.sticker_engine_pure import (
build_drawtext_alpha_expr,
build_enable_expr,
build_image_fade_filters,
build_opacity_filter,
build_overlay_position,
build_pre_filter_label,
build_scale_filter,
build_shadow_params,
build_stroke_params,
calculate_end_time,
calculate_fade_out_start,
count_sticker_types,
escape_drawtext_text,
estimate_sticker_size,
estimate_text_size,
filter_enabled_stickers,
has_time_range,
safe_bool,
safe_float,
safe_int,
sort_stickers_by_z_index,
validate_image_sticker,
validate_text_sticker,
)
# ─────────────────────────────────────────────────────────────────────────────
# 安全类型转换测试
# ─────────────────────────────────────────────────────────────────────────────
class TestSafeFloat:
"""safe_float 测试."""
def test_int_input(self):
"""整数输入."""
assert safe_float(42) == 42.0
def test_float_input(self):
"""浮点数输入."""
assert safe_float(3.14) == 3.14
def test_string_number(self):
"""字符串数字."""
assert safe_float("3.14") == 3.14
def test_string_int(self):
"""字符串整数."""
assert safe_float("100") == 100.0
def test_none_input(self):
"""None 输入."""
assert safe_float(None) is None
def test_invalid_string(self):
"""无效字符串."""
assert safe_float("abc") is None
def test_empty_string(self):
"""空字符串."""
assert safe_float("") is None
def test_zero(self):
"""零值."""
assert safe_float(0) == 0.0
def test_negative(self):
"""负值."""
assert safe_float(-5.5) == -5.5
class TestSafeInt:
"""safe_int 测试."""
def test_int_input(self):
"""整数输入."""
assert safe_int(42) == 42
def test_float_input(self):
"""浮点数输入(截断)."""
assert safe_int(3.7) == 3
def test_string_number(self):
"""字符串数字."""
assert safe_int("42") == 42
def test_none_input(self):
"""None 输入用默认值."""
assert safe_int(None) == 0
def test_none_custom_default(self):
"""None 输入自定义默认值."""
assert safe_int(None, default=10) == 10
def test_invalid_string(self):
"""无效字符串."""
assert safe_int("abc") == 0
def test_negative(self):
"""负值."""
assert safe_int(-5) == -5
def test_zero(self):
"""零值."""
assert safe_int(0) == 0
class TestSafeBool:
"""safe_bool 测试."""
def test_true_bool(self):
"""True."""
assert safe_bool(True) is True
def test_false_bool(self):
"""False."""
assert safe_bool(False) is False
def test_none(self):
"""None -> False."""
assert safe_bool(None) is False
def test_string_true(self):
"""字符串 true."""
assert safe_bool("true") is True
def test_string_yes(self):
"""字符串 yes."""
assert safe_bool("yes") is True
def test_string_one(self):
"""字符串 1."""
assert safe_bool("1") is True
def test_string_false(self):
"""字符串 false."""
assert safe_bool("false") is False
def test_int_one(self):
"""整数 1 -> True."""
assert safe_bool(1) is True
def test_int_zero(self):
"""整数 0 -> False."""
assert safe_bool(0) is False
def test_empty_list(self):
"""空列表 -> False."""
assert safe_bool([]) is False
# ─────────────────────────────────────────────────────────────────────────────
# 尺寸估算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEstimateStickerSize:
"""贴纸尺寸估算测试."""
def test_default_scale(self):
"""默认 scale=1.0."""
w, h = estimate_sticker_size(1000, 1000)
assert w == 300 # 1000 * 0.3 * 1.0
assert h == 300
def test_custom_scale(self):
"""自定义缩放."""
w, h = estimate_sticker_size(1000, 1000, scale=0.5)
assert w == 150
assert h == 150
def test_fixed_width_height(self):
"""固定宽高."""
w, h = estimate_sticker_size(1000, 1000, fixed_width=200, fixed_height=100)
assert w == 200
assert h == 100
def test_scale_2x(self):
"""2倍缩放."""
w, h = estimate_sticker_size(800, 600, scale=2.0)
assert w == 480 # 800 * 0.3 * 2
assert h == 360 # 600 * 0.3 * 2
def test_zero_canvas(self):
"""零画布尺寸,返回最小 1."""
w, h = estimate_sticker_size(0, 0)
assert w >= 1
assert h >= 1
class TestEstimateTextSize:
"""文字尺寸估算测试."""
def test_normal_text(self):
"""普通文字."""
w, h = estimate_text_size("Hello", 36)
assert w == int(5 * 36 * 0.6)
assert h == int(36 * 1.4)
def test_empty_text(self):
"""空文字."""
w, h = estimate_text_size("", 36)
assert w == 0
assert h == 0
def test_large_font(self):
"""大字号."""
w, h = estimate_text_size("A", 72)
assert w == int(1 * 72 * 0.6)
assert h == int(72 * 1.4)
def test_chinese_chars(self):
"""中文字符."""
w, h = estimate_text_size("你好世界", 48)
assert w == int(4 * 48 * 0.6)
assert h == int(48 * 1.4)
# ─────────────────────────────────────────────────────────────────────────────
# 时间计算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateFadeOutStart:
"""淡出开始时间计算测试."""
def test_normal_case(self):
"""正常情况."""
assert calculate_fade_out_start(10, 30, 2) == pytest.approx(38.0)
def test_no_fade_out(self):
"""无淡出."""
assert calculate_fade_out_start(10, 30, 0) == 0.0
def test_negative_fade_out(self):
"""负淡出."""
assert calculate_fade_out_start(10, 30, -1) == 0.0
def test_zero_duration(self):
"""零时长."""
assert calculate_fade_out_start(10, 0, 2) == 0.0
def test_fade_out_longer_than_duration(self):
"""淡出超过时长,返回 0."""
# start=10, dur=5, fade=10 -> 10+5-10 = 5 > 0
assert calculate_fade_out_start(10, 5, 10) == pytest.approx(5.0)
def test_fade_out_starts_before_zero(self):
"""淡出开始时间在 0 之前,钳制到 0."""
# start=0, dur=3, fade=5 -> 0+3-5 = -2 -> 0
assert calculate_fade_out_start(0, 3, 5) == 0.0
class TestCalculateEndTime:
"""结束时间计算测试."""
def test_normal_case(self):
"""正常情况."""
assert calculate_end_time(10, 30) == 40.0
def test_zero_duration(self):
"""零时长."""
assert calculate_end_time(10, 0) == 10.0
def test_negative_duration(self):
"""负时长."""
assert calculate_end_time(10, -5) == 10.0
def test_zero_start(self):
"""零开始."""
assert calculate_end_time(0, 100) == 100.0
class TestHasTimeRange:
"""时间范围判断测试."""
def test_positive_duration(self):
"""正时长."""
assert has_time_range(30) is True
def test_zero_duration(self):
"""零时长."""
assert has_time_range(0) is False
def test_negative_duration(self):
"""负时长."""
assert has_time_range(-5) is False
# ─────────────────────────────────────────────────────────────────────────────
# 滤镜构建测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildScaleFilter:
"""缩放滤镜构建测试."""
def test_fixed_width_height(self):
"""固定宽高."""
result = build_scale_filter(width=200, height=100)
assert result == "scale=200:100"
def test_scale_only(self):
"""仅缩放."""
result = build_scale_filter(scale=0.5)
assert result == "scale=iw*0.5:ih*0.5"
def test_no_scaling_needed(self):
"""无需缩放."""
result = build_scale_filter(scale=1.0)
assert result is None
def test_scale_2x(self):
"""2倍缩放."""
result = build_scale_filter(scale=2.0)
assert result == "scale=iw*2.0:ih*2.0"
def test_fixed_overrides_scale(self):
"""固定宽高优先于 scale."""
result = build_scale_filter(width=100, height=50, scale=0.5)
assert result == "scale=100:50"
class TestBuildOpacityFilter:
"""透明度滤镜构建测试."""
def test_partial_opacity(self):
"""部分透明."""
result = build_opacity_filter(0.5)
assert result == "colorchannelmixer=aa=0.5"
def test_fully_opaque(self):
"""完全不透明."""
result = build_opacity_filter(1.0)
assert result is None
def test_fully_transparent(self):
"""完全透明."""
result = build_opacity_filter(0.0)
assert result == "colorchannelmixer=aa=0.0"
def test_opacity_above_1_clamped(self):
"""超过 1 被钳制."""
result = build_opacity_filter(1.5)
assert result is None
def test_opacity_below_0_clamped(self):
"""低于 0 被钳制."""
result = build_opacity_filter(-0.5)
assert result == "colorchannelmixer=aa=0.0"
class TestBuildImageFadeFilters:
"""图片淡入淡出滤镜测试."""
def test_fade_in_only(self):
"""仅淡入."""
result = build_image_fade_filters(10, 30, fade_in=1.0)
assert len(result) == 1
assert "fade=in:st=10:d=1.0:alpha=1" in result[0]
def test_fade_out_only(self):
"""仅淡出."""
result = build_image_fade_filters(10, 30, fade_out=2.0)
assert len(result) == 1
assert "fade=out" in result[0]
assert "st=38.0" in result[0] # 10 + 30 - 2 = 38
def test_fade_in_and_out(self):
"""淡入+淡出."""
result = build_image_fade_filters(0, 10, fade_in=1.0, fade_out=1.0)
assert len(result) == 2
assert "fade=in" in result[0]
assert "fade=out" in result[1]
def test_no_fade(self):
"""无淡入淡出."""
result = build_image_fade_filters(10, 30)
assert len(result) == 0
def test_zero_duration_no_fade_out(self):
"""零时长不生成淡出."""
result = build_image_fade_filters(10, 0, fade_out=1.0)
assert len(result) == 0
class TestBuildEnableExpr:
"""enable 表达式构建测试."""
def test_normal_duration(self):
"""正常时长."""
result = build_enable_expr(10, 30)
assert "between(t,10,40" in result
assert "enable" in result
def test_zero_duration(self):
"""零时长返回空."""
result = build_enable_expr(10, 0)
assert result == ""
def test_negative_duration(self):
"""负时长返回空."""
result = build_enable_expr(10, -5)
assert result == ""
def test_zero_start(self):
"""从零开始."""
result = build_enable_expr(0, 100)
assert "t,0,100" in result
# ─────────────────────────────────────────────────────────────────────────────
# drawtext 相关测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEscapeDrawtextText:
"""文字转义测试."""
def test_no_special_chars(self):
"""无特殊字符."""
assert escape_drawtext_text("Hello") == "Hello"
def test_colon_escaped(self):
"""冒号转义."""
assert escape_drawtext_text("a:b") == "a\\:b"
def test_quote_escaped(self):
"""单引号转义."""
assert escape_drawtext_text("it's") == "it\\'s"
def test_multiple_special_chars(self):
"""多个特殊字符."""
assert escape_drawtext_text("a:b:c'd") == "a\\:b\\:c\\'d"
def test_empty_string(self):
"""空字符串."""
assert escape_drawtext_text("") == ""
class TestBuildDrawtextAlphaExpr:
"""drawtext alpha 表达式测试."""
def test_no_fade(self):
"""无淡入淡出."""
assert build_drawtext_alpha_expr(10, 30) == "1"
def test_fade_in_only(self):
"""仅淡入."""
result = build_drawtext_alpha_expr(10, 30, fade_in=2.0)
assert "if(lt(t,12.0)" in result
assert "(t-10)/2.0" in result
def test_fade_out_only(self):
"""仅淡出."""
result = build_drawtext_alpha_expr(10, 30, fade_out=3.0)
assert "if(gt(t,37" in result
assert "-t)/3.0" in result
def test_fade_in_and_out(self):
"""淡入+淡出(相乘)."""
result = build_drawtext_alpha_expr(0, 10, fade_in=1.0, fade_out=1.0)
assert "*" in result
assert result.count("if(") == 2
def test_zero_duration_no_fade_out(self):
"""零时长不生成淡出."""
result = build_drawtext_alpha_expr(10, 0, fade_out=1.0)
assert result == "1"
class TestBuildStrokeParams:
"""描边参数测试."""
def test_no_stroke(self):
"""无描边."""
result = build_stroke_params(0)
assert len(result) == 0
def test_with_stroke(self):
"""有描边."""
result = build_stroke_params(2, "red")
assert len(result) == 2
assert "borderw=2" in result
assert "bordercolor=red" in result
def test_negative_width(self):
"""负宽度."""
result = build_stroke_params(-1)
assert len(result) == 0
class TestBuildShadowParams:
"""阴影参数测试."""
def test_no_shadow(self):
"""无阴影."""
result = build_shadow_params(0)
assert len(result) == 0
def test_with_shadow(self):
"""有阴影."""
result = build_shadow_params(0.5, 3, 4, "black")
assert len(result) == 3
assert "shadowx=3" in result
assert "shadowy=4" in result
assert "shadowcolor=black@0.5" in result
def test_shadow_alpha_clamped(self):
"""透明度钳制."""
result = build_shadow_params(1.5)
assert "shadowcolor=black@1.0" in result[2]
# ─────────────────────────────────────────────────────────────────────────────
# 贴纸排序与过滤测试
# ─────────────────────────────────────────────────────────────────────────────
class TestSortStickersByZIndex:
"""贴纸排序测试."""
def test_sorted_by_z_index(self):
"""按 z_index 排序."""
stickers = [
{"z_index": 20, "name": "top"},
{"z_index": 5, "name": "bottom"},
{"z_index": 10, "name": "middle"},
]
result = sort_stickers_by_z_index(stickers)
assert result[0]["name"] == "bottom"
assert result[1]["name"] == "middle"
assert result[2]["name"] == "top"
def test_same_z_index_preserves_order(self):
"""相同 z_index 保持原顺序."""
stickers = [
{"z_index": 10, "name": "first"},
{"z_index": 10, "name": "second"},
]
result = sort_stickers_by_z_index(stickers)
assert result[0]["name"] == "first"
assert result[1]["name"] == "second"
def test_empty_list(self):
"""空列表."""
assert sort_stickers_by_z_index([]) == []
def test_default_z_index_10(self):
"""无 z_index 默认 10."""
stickers = [
{"z_index": 5, "name": "low"},
{"name": "default"},
]
result = sort_stickers_by_z_index(stickers)
assert result[0]["name"] == "low"
assert result[1]["name"] == "default"
class TestFilterEnabledStickers:
"""启用贴纸过滤测试."""
def test_all_enabled(self):
"""全部启用."""
stickers = [{"enabled": True}, {"enabled": True}]
assert len(filter_enabled_stickers(stickers)) == 2
def test_mixed(self):
"""混合."""
stickers = [
{"enabled": True, "name": "a"},
{"enabled": False, "name": "b"},
{"enabled": True, "name": "c"},
]
result = filter_enabled_stickers(stickers)
assert len(result) == 2
assert result[0]["name"] == "a"
def test_default_enabled(self):
"""默认启用."""
stickers = [{"name": "a"}]
result = filter_enabled_stickers(stickers)
assert len(result) == 1
def test_empty_list(self):
"""空列表."""
assert filter_enabled_stickers([]) == []
class TestCountStickerTypes:
"""贴纸类型统计测试."""
def test_mixed_types(self):
"""混合类型."""
stickers = [
{"type": "image"},
{"type": "text"},
{"type": "image"},
]
counts = count_sticker_types(stickers)
assert counts["image"] == 2
assert counts["text"] == 1
def test_default_type(self):
"""默认 image."""
stickers = [{}]
counts = count_sticker_types(stickers)
assert counts["image"] == 1
def test_empty_list(self):
"""空列表."""
assert count_sticker_types([]) == {}
# ─────────────────────────────────────────────────────────────────────────────
# overlay 相关测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildOverlayPosition:
"""overlay 位置构建测试."""
def test_integer_position(self):
"""整数位置."""
assert build_overlay_position(100, 200) == "100:200"
def test_float_position_rounded(self):
"""浮点取整."""
assert build_overlay_position(100.6, 200.4) == "101:200"
def test_zero_position(self):
"""零位置."""
assert build_overlay_position(0, 0) == "0:0"
def test_negative_position(self):
"""负位置."""
assert build_overlay_position(-10, -20) == "-10:-20"
class TestBuildPreFilterLabel:
"""预处理标签构建测试."""
def test_normal_idx(self):
"""正常索引."""
assert build_pre_filter_label(3) == "sticker_3_scaled"
def test_zero_idx(self):
"""零索引."""
assert build_pre_filter_label(0) == "sticker_0_scaled"
# ─────────────────────────────────────────────────────────────────────────────
# 验证函数测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateImageSticker:
"""图片贴纸验证测试."""
def test_valid_with_image_path(self):
"""有 image_path,合法."""
ok, errors = validate_image_sticker({"image_path": "/a.png"})
assert ok is True
assert len(errors) == 0
def test_valid_with_asset_id(self):
"""有 asset_id,合法."""
ok, errors = validate_image_sticker({"asset_id": "123"})
assert ok is True
def test_missing_image_source(self):
"""缺图片来源."""
ok, errors = validate_image_sticker({})
assert ok is False
assert any("image_path" in e or "asset_id" in e for e in errors)
def test_opacity_out_of_range(self):
"""透明度超范围."""
ok, errors = validate_image_sticker(
{
"image_path": "/a.png",
"opacity": 1.5,
}
)
assert ok is False
assert any("opacity" in e for e in errors)
def test_negative_scale(self):
"""负缩放."""
ok, errors = validate_image_sticker(
{
"image_path": "/a.png",
"scale": -0.5,
}
)
assert ok is False
assert any("scale" in e for e in errors)
def test_negative_duration(self):
"""负时长."""
ok, errors = validate_image_sticker(
{
"image_path": "/a.png",
"duration": -10,
}
)
assert ok is False
assert any("duration" in e for e in errors)
def test_multiple_errors(self):
"""多个错误."""
ok, errors = validate_image_sticker(
{
"opacity": 1.5,
"duration": -1,
"start_time": -5,
}
)
assert ok is False
assert len(errors) >= 3
class TestValidateTextSticker:
"""文字贴纸验证测试."""
def test_valid(self):
"""合法配置."""
ok, errors = validate_text_sticker(
{
"text": "Hello",
"font_size": 36,
"font_color": "white",
}
)
assert ok is True
assert len(errors) == 0
def test_empty_text(self):
"""空文字."""
ok, errors = validate_text_sticker({"text": ""})
assert ok is False
assert any("text" in e for e in errors)
def test_zero_font_size(self):
"""零字号."""
ok, errors = validate_text_sticker(
{
"text": "Hi",
"font_size": 0,
}
)
assert ok is False
assert any("font_size" in e for e in errors)
def test_empty_font_color(self):
"""空颜色."""
ok, errors = validate_text_sticker(
{
"text": "Hi",
"font_color": "",
}
)
assert ok is False
assert any("font_color" in e for e in errors)
def test_negative_duration(self):
"""负时长."""
ok, errors = validate_text_sticker(
{
"text": "Hi",
"duration": -5,
}
)
assert ok is False
assert any("duration" in e for e in errors)
+61 -22
View File
@@ -10,7 +10,6 @@ from packages.domain.template_version import EditTemplateVersion
from packages.domain.title_library import TitleLibraryItem
from packages.domain.voice_library import VoiceLibraryItem
# ── EditTemplateVersion ────────────────────────────────────────────────────
@@ -55,7 +54,8 @@ class TestEditTemplateVersion:
def test_create_with_change_note(self):
v = EditTemplateVersion.create(
template_id="t1", version=2,
template_id="t1",
version=2,
change_note="修复时长计算问题",
)
assert v.change_note == "修复时长计算问题"
@@ -101,8 +101,11 @@ class TestTemplate:
def test_template_with_segments(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0,
duration_min=2.0, duration_max=5.0,
id="s1",
template_id="t1",
segment_order=0,
duration_min=2.0,
duration_max=5.0,
)
t = Template(id="t1", user_id="u1", name="T1", mode="pip", segments=[seg])
assert len(t.segments) == 1
@@ -114,16 +117,22 @@ class TestTemplate:
def test_template_segment_defaults(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0,
duration_min=1.0, duration_max=3.0,
id="s1",
template_id="t1",
segment_order=0,
duration_min=1.0,
duration_max=3.0,
)
assert seg.material_type is None
assert seg.created_at is not None
def test_template_segment_with_material_type(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0,
duration_min=1.0, duration_max=3.0,
id="s1",
template_id="t1",
segment_order=0,
duration_min=1.0,
duration_max=3.0,
material_type="人物",
)
assert seg.material_type == "人物"
@@ -156,8 +165,11 @@ class TestRecipe:
def test_recipe_with_items(self):
item = RecipeItem(
id="i1", recipe_id="r1",
item_type="asset", item_id="asset_1", position=0,
id="i1",
recipe_id="r1",
item_type="asset",
item_id="asset_1",
position=0,
)
r = Recipe(id="r1", user_id="u1", name="R1", items=[item])
assert len(r.items) == 1
@@ -170,8 +182,12 @@ class TestRecipe:
def test_recipe_item_with_metadata(self):
item = RecipeItem(
id="i1", recipe_id="r1", item_type="voice", item_id="v1",
position=2, metadata_={"speed": 1.2},
id="i1",
recipe_id="r1",
item_type="voice",
item_id="v1",
position=2,
metadata_={"speed": 1.2},
)
assert item.position == 2
assert item.metadata_ == {"speed": 1.2}
@@ -183,7 +199,10 @@ class TestRecipe:
class TestTitleLibraryItem:
def test_defaults(self):
item = TitleLibraryItem(
id="t1", user_id="u1", name="标题1", text="欢迎收看",
id="t1",
user_id="u1",
name="标题1",
text="欢迎收看",
)
assert item.id == "t1"
assert item.user_id == "u1"
@@ -200,21 +219,30 @@ class TestTitleLibraryItem:
def test_with_category(self):
item = TitleLibraryItem(
id="t1", user_id="u1", name="t1", text="txt",
id="t1",
user_id="u1",
name="t1",
text="txt",
category="opening",
)
assert item.category == "opening"
def test_with_tags(self):
item = TitleLibraryItem(
id="t1", user_id="u1", name="t1", text="txt",
id="t1",
user_id="u1",
name="t1",
text="txt",
tags=["搞笑", "热门"],
)
assert item.tags == ["搞笑", "热门"]
def test_with_usage_count(self):
item = TitleLibraryItem(
id="t1", user_id="u1", name="t1", text="txt",
id="t1",
user_id="u1",
name="t1",
text="txt",
usage_count=42,
)
assert item.usage_count == 42
@@ -226,7 +254,9 @@ class TestTitleLibraryItem:
class TestVoiceLibraryItem:
def test_defaults(self):
item = VoiceLibraryItem(
id="v1", user_id="u1", name="温柔女声",
id="v1",
user_id="u1",
name="温柔女声",
)
assert item.id == "v1"
assert item.user_id == "u1"
@@ -247,10 +277,15 @@ class TestVoiceLibraryItem:
def test_with_voice_info(self):
item = VoiceLibraryItem(
id="v1", user_id="u1", name="v1",
voice_provider="cosyvoice", voice_id="voice_001", voice_name="小溪",
id="v1",
user_id="u1",
name="v1",
voice_provider="cosyvoice",
voice_id="voice_001",
voice_name="小溪",
audio_url="https://example.com/audio.mp3",
duration=15.5, file_size=320000,
duration=15.5,
file_size=320000,
)
assert item.voice_provider == "cosyvoice"
assert item.voice_id == "voice_001"
@@ -260,14 +295,18 @@ class TestVoiceLibraryItem:
def test_with_project_id(self):
item = VoiceLibraryItem(
id="v1", user_id="u1", name="v1",
id="v1",
user_id="u1",
name="v1",
project_id="proj_123",
)
assert item.project_id == "proj_123"
def test_with_tags(self):
item = VoiceLibraryItem(
id="v1", user_id="u1", name="v1",
id="v1",
user_id="u1",
name="v1",
tags=["温柔", "女声"],
)
assert item.tags == ["温柔", "女声"]
+1 -1
View File
@@ -6,7 +6,7 @@ import pytest
from packages.application.tts_job.text_splitter import split_text
from packages.domain.tts_config import TtsConfig
from packages.domain.tts_job import TTSJob, TTSJobStatus, TERMINAL_STATUSES
from packages.domain.tts_job import TERMINAL_STATUSES, TTSJob, TTSJobStatus
# ═══════════════════════════════════════════════════════════════════════════════
# text_splitter 文本分段