Compare commits

...

30 Commits

Author SHA1 Message Date
CI Bot a6aab85825 style: auto-format with black + isort + prettier [skip ci-format-check] 2026-07-28 08:33:47 +00:00
xiaoxia e255f27cd7 chore: retrigger CI
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
Preview Cleanup / Cleanup Preview Environment (pull_request) Waiting to run
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Build Production API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Worker Image (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 57s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m2s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m5s
AI Code Review / AI Code Review (pull_request) Successful in 1m44s
CI/CD Pipeline / Integration Tests (pull_request) Waiting to run
PR Automation / Auto Approve on CI Green (pull_request) Failing after 58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m12s
2026-07-28 16:06:05 +08:00
xiaoxia ee39086a53 refactor(generate): 拆分Step7ConfirmGenerate为子组件(194→88行, -55%) 2026-07-28 16:06:05 +08:00
xiaoxia 39187a0660 fix: 修复 20 个 ruff lint 错误,解除所有 PR CI 阻塞
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
F401 (14): 移除未使用的 import
  - multi_track_mixer_pure.py: Any, Optional, math
  - pip_engine_pure.py: Any
  - speed_engine.py: Optional, MAX_SPEED, MIN_SPEED
  - url_security.py: ALLOWED_AUDIO_MIME_TYPES, ALLOWED_IMAGE_MIME_TYPES,
    ALLOWED_VIDEO_MIME_TYPES, MAGIC_NUMBERS, MAX_URL_LENGTH,
    check_internal_hostname, is_trusted_domain

E741 (3): 重命名模糊变量 l → layer
  - pip_engine_pure.py: lambda 参数
  - test_pip_engine_pure.py: 两处列表推导

B017 (2): pytest.raises(Exception) → FrozenInstanceError
  - test_video_filter_builder.py
  - test_transition_presets.py

B905 (1): zip() 补充 strict=True
  - pip_engine_pure.py
2026-07-28 16:03:01 +08:00
xiaoxia f0bbedab23 refactor(voice-materials): 拆分 VoiceMaterialRow 进度条和标签组件(186→138行, -26%) (#1087)
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
Squash merge PR #1087: refactor(voice-materials): 拆分 VoiceMaterialRow 进度条和标签组件(186→138行, -26%)

- 拆分 VoiceMaterialRow 为子组件
- 抽取 TagDisplay 标签展示组件
- 抽取 useRowProgress 进度条Hook
- 186→138行, 精简 -26%
2026-07-28 15:55:52 +08:00
xiaoxia 750444c8bb fix(ci): 修复测试收集错误 — 模块名冲突 + SQLite 兼容 PG 特定 SQL (#1075)
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
- 修复test_url_security.py等模块名冲突导致的测试收集错误
- SQLite兼容PG特定SQL语法
- 调整pytest.ini配置

Squash merged from fix/ci-test-collection-errors
2026-07-28 14:34:04 +08:00
xiaoxia 581a146d2f refactor(voice-materials): 拆分 MaterialForm 为子组件(186→108行, -42%)
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 14:07:02 +08:00
xiaoxia eab45e0819 refactor(voices): 拆分 CloneVoiceCard 为子组件(180→79行, -56%)
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 14:05:47 +08:00
xiaoxia e243d70082 refactor(voice-materials): 拆分 TagSelector 逻辑到 useTagInput Hook(163→107行, -34%)
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 14:04:54 +08:00
xiaoxia d4c3743e45 fix(ci): 修复CI Gate失败时返回exit 0的P0 Bug (develop) (#1058)
CI/CD Pipeline / Deploy Production (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 2m42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m37s
CI/CD Pipeline / Unit Tests (push) Failing after 6m22s
CI/CD Pipeline / Integration Tests (push) Successful in 4m51s
CI/CD Pipeline / Frontend Lint (push) Failing after 46s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m17s
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 18m12s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 3m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m19s
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 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 / 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 13:33:25 +08:00
xiaoxia 07d9b56fe7 refactor(duplication): 拆分 DuplicationUpload 页面(233→85行, -64%) (#1064)
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 13:23:29 +08:00
xiaoxia 11b3f83368 refactor(generate): 拆分 useStep2Materials Hook(205→79行, -61%) (#1063)
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
2026-07-28 13:09:11 +08:00
xiaoxia 1dc40b4760 refactor(voice): 拆分 useCloneModal Hook(215→46行, -79%) (#1062) 2026-07-28 13:08:00 +08:00
xiaoxia 29448aaf9b refactor(voice-materials): 拆分 useVoiceMaterialActions Hook(216→80行, -63%) (#1059)
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 3m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m33s
CI/CD Pipeline / Unit Tests (push) Failing after 6m45s
CI/CD Pipeline / Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Frontend Lint (push) Failing after 51s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m41s
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 16m10s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m22s
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) Failing after 14m50s
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 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 12:49:52 +08:00
xiaoxia c73c4be367 test(wave150): tts_config 单测补全 +57 (#1073)
CI/CD Pipeline / CI Gate (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 5m18s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m32s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m34s
CI/CD Pipeline / Unit Tests (push) Failing after 5m48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m15s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m45s
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 19m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m36s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m59s
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
2026-07-28 12:25:56 +08:00
xiaoxia c31bc96855 test(wave149): video_concat 单测补全 +65 (#1072)
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 12:25:54 +08:00
xiaoxia 002384bad4 test(wave148): voice_presets 单测补全 +59 (#1071)
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 12:24:10 +08:00
xiaoxia db6b742ebb test(wave147): transition_config 单测补全 +78 (#1070)
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 12:24:00 +08:00
xiaoxia 88ca8b4406 test(wave145): template_clip_converter 单测补全 +79 (#1068)
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 12:23:52 +08:00
xiaoxia 005b34d0dd test(wave143): voice_clone_profile 单测补全 +82 (#1066)
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 12:23:48 +08:00
xiaoxia e2ddc679bb test(wave146): trim_config 单测补全 +78 (#1069)
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 12:23:46 +08:00
xiaoxia 39990f7a07 Merge pull request 'test(wave144): tts_job 单测补全 +90' (#1067) from test/wave144-tts-job into develop
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 12:21:57 +08:00
xiaoxia 6fcb4cd70c Merge pull request 'test(wave142): transition_presets 单测补全 +65' (#1065) from test/wave142-transition-presets into develop
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 12:21:33 +08:00
xiaoxia 14fefc7b1a test(wave141): xfade_builder domain 层 +63 单测 (#1061)
CI/CD Pipeline / Deploy Production (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 1m35s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m3s
CI/CD Pipeline / Unit Tests (push) Failing after 4m57s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Frontend Lint (push) Successful in 31s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m11s
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 13m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m20s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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
PR #1061
- xfade_builder domain 层单测补全
- 新增 tests/unit/domain/test_xfade_builder.py +579行
2026-07-28 11:34:47 +08:00
xiaoxia fa8ab58fc0 test(wave144): add tts_job unit tests (+90)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 19s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m14s
AI Code Review / AI Code Review (pull_request) Successful in 58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
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) Successful in 3m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m10s
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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 24s
- TTSJobStatus 枚举: 5种状态值、字符串比较、从字符串构建
- TERMINAL_STATUSES: 3个终态验证
- create 工厂方法: 基础创建、voice_id/project_id/voice_clone_profile_id
- 采样率/格式(format参数化mp3/wav/pcm)、max_retries、metadata
- 参数校验: 空user_id/空input_text/超长文本(10000)/无效格式/纯空白
- 属性: is_terminal(5种)、is_retryable(5种)、is_completed(4种)
- transition_to: 合法转换(8条)、非法转换(4条)、字符串输入、updated_at更新
- mark_*方法: mark_processing(started_at)、mark_completed(全部字段)、mark_failed、mark_cancelled
- prepare_retry: 正常重试、清除error/started_at/completed_at、超上限不可重试
- to_dict: 字段完整性、值正确性、completed/failed状态、空时间字段、ISO时间格式
2026-07-28 10:42:11 +08:00
xiaoxia 4765d83c8b test(wave142): add transition_presets unit tests (+65)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 51s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m57s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m50s
AI Code Review / AI Code Review (pull_request) Successful in 2m4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m24s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m3s
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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
- TransitionPreset 数据类: 属性、默认值、frozen不可变、相等性
- 预设库完整性: 数量、ID唯一、必填字段、时长范围、分类验证
- get_transition_preset: 存在/不存在、随机、空串、大小写、同实例
- list_transition_presets: 无筛选、按分类、关键词搜索(name/desc/tags)、大小写不敏感、组合筛选、顺序保持
- get_default_transition: 默认值、时长、分类、同实例
- 14个典型预设属性参数化验证
2026-07-28 10:34:58 +08:00
xiaoxia 395bc37cf0 refactor(templates): 拆分 TemplateDetailModal 为子组件(219→106行, -52%) (#1055)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m48s
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 50s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m9s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / 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:34:19 +08:00
xiaoxia 15b6e17552 test(wave138): video_filter_builder domain 层 +71 单测 (#1053)
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:33:08 +08:00
xiaoxia a6e147ed30 refactor(voice-materials): 拆分 VoiceMaterialCard 为子组件(245→72行, -71%) (#1054)
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:19:04 +08:00
xiaoxia 0d46d71b2d test(wave137): pip_engine 纯逻辑抽离 + 96 单测 (#1052) 2026-07-28 10:18:55 +08:00
75 changed files with 9175 additions and 3394 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
@@ -0,0 +1,134 @@
import { useState, useRef, useCallback, useEffect } from "react"
import type { ModalPhase } from "../types/cloneModal"
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
import useAudioRecorder from "./useAudioRecorder"
/**
* 克隆弹窗表单状态 Hook
* 管理表单字段、录音、文件选择、验证逻辑
*/
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
useAudioRecorder()
/** 默认音色名称计数器 */
const cloneCounterRef = useRef(1)
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
const hasAudio = selectedFile !== null || recordedBlob !== null
const canSubmit =
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
hasAudio
const isProcessing = phase === "uploading" || phase === "cloning"
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setErrorMessage("")
resetRecording()
}, [getNextDefaultName, resetRecording])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState()
}
}, [open, resetState])
/** 选择文件(来自上传或拖拽) */
const handleFileSelect = useCallback(
(file: File | null, error: string) => {
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
// 清除录音
resetRecording()
}
},
[resetRecording],
)
/** 录音切换 */
const handleRecordToggle = useCallback(() => {
setErrorMessage("")
if (isRecording) {
toggleRecording()
} else {
// 开始录制前清除已选文件
setSelectedFile(null)
toggleRecording()
}
}, [isRecording, toggleRecording])
/** 表单验证 */
const validateForm = useCallback((): string | null => {
const name = voiceName.trim()
if (!name) {
return "请输入音色名称"
}
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音"
}
return null
}, [voiceName, hasAudio])
return {
// 状态
phase,
setPhase,
voiceName,
setVoiceName,
voiceDescription,
setVoiceDescription,
selectedFile,
dragActive,
setDragActive,
errorMessage,
setErrorMessage,
// 录音
isRecording,
recordTime,
recordedBlob,
// 计算属性
hasAudio,
canSubmit,
isProcessing,
// handlers
handleFileSelect,
handleRecordToggle,
handleClose,
validateForm,
resetState,
}
}
+33 -202
View File
@@ -1,213 +1,44 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAsset } from "@/api/assets"
import type { ModalPhase, CloneModalProps } from "../types/cloneModal"
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
import useAudioRecorder from "./useAudioRecorder"
interface UseCloneModalReturn {
phase: ModalPhase
voiceName: string
voiceDescription: string
selectedFile: File | null
dragActive: boolean
errorMessage: string
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
canSubmit: boolean
isProcessing: boolean
setVoiceName: (value: string) => void
setVoiceDescription: (value: string) => void
setDragActive: (active: boolean) => void
handleFileSelect: (file: File | null, error: string) => void
handleRecordToggle: () => void
handleClose: () => void
handleSubmit: () => void
}
import type { CloneModalProps } from "../types/cloneModal"
import { useCloneFormState } from "./useCloneFormState"
import { useCloneSubmit } from "./useCloneSubmit"
/**
* 音色克隆弹窗主业务 Hook
* 组合表单状态 + 提交流程两个子 Hook
*/
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => {
const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
const formState = useCloneFormState({ open, onClose })
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
useAudioRecorder()
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 默认音色名称计数器 */
const cloneCounterRef = useRef(1)
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
const hasAudio = selectedFile !== null || recordedBlob !== null
const canSubmit =
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
hasAudio
const isProcessing = phase === "uploading" || phase === "cloning"
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setErrorMessage("")
resetRecording()
}, [getNextDefaultName, resetRecording])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState()
}
}, [open, resetState])
/** 组件卸载时清理定时器 */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
/** 选择文件(来自上传或拖拽) */
const handleFileSelect = useCallback(
(file: File | null, error: string) => {
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
// 清除录音
resetRecording()
}
},
[resetRecording],
)
/** 录音切换 */
const handleRecordToggle = useCallback(() => {
setErrorMessage("")
if (isRecording) {
toggleRecording()
} else {
// 开始录制前清除已选文件
setSelectedFile(null)
toggleRecording()
}
}, [isRecording, toggleRecording])
/** 表单验证 */
const validateForm = useCallback((): string | null => {
const name = voiceName.trim()
if (!name) {
return "请输入音色名称"
}
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音"
}
return null
}, [voiceName, hasAudio])
/** 提交克隆 */
const handleSubmit = useCallback(async () => {
const formError = validateForm()
if (formError) {
setErrorMessage(formError)
return
}
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
const formData = new FormData()
formData.append("file", fileToUpload)
const uploadResult = await uploadAsset(formData)
// 阶段 2:克隆
setPhase("cloning")
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
})
// 阶段 3:完成
setPhase("done")
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result))
handleClose()
}, 2000)
} catch (err) {
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
}, [
validateForm,
selectedFile,
recordedBlob,
voiceName,
voiceDescription,
const { handleSubmit } = useCloneSubmit({
voiceName: formState.voiceName,
voiceDescription: formState.voiceDescription,
selectedFile: formState.selectedFile,
recordedBlob: formState.recordedBlob,
setPhase: formState.setPhase,
setErrorMessage: formState.setErrorMessage,
validateForm: formState.validateForm,
onSuccess,
handleClose,
])
onClose: formState.handleClose,
})
return {
phase,
voiceName,
voiceDescription,
selectedFile,
dragActive,
errorMessage,
isRecording,
recordTime,
recordedBlob,
canSubmit,
isProcessing,
setVoiceName,
setVoiceDescription,
setDragActive,
handleFileSelect,
handleRecordToggle,
handleClose,
phase: formState.phase,
voiceName: formState.voiceName,
voiceDescription: formState.voiceDescription,
selectedFile: formState.selectedFile,
dragActive: formState.dragActive,
errorMessage: formState.errorMessage,
isRecording: formState.isRecording,
recordTime: formState.recordTime,
recordedBlob: formState.recordedBlob,
canSubmit: formState.canSubmit,
isProcessing: formState.isProcessing,
setVoiceName: formState.setVoiceName,
setVoiceDescription: formState.setVoiceDescription,
setDragActive: formState.setDragActive,
handleFileSelect: formState.handleFileSelect,
handleRecordToggle: formState.handleRecordToggle,
handleClose: formState.handleClose,
handleSubmit,
}
}
+101
View File
@@ -0,0 +1,101 @@
import { useRef, useCallback, useEffect } from "react"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAsset } from "@/api/assets"
import type { VoiceClone } from "@/api/voice-clone"
interface UseCloneSubmitOptions {
voiceName: string
voiceDescription: string
selectedFile: File | null
recordedBlob: Blob | null
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
setErrorMessage: (msg: string) => void
validateForm: () => string | null
onSuccess?: (clone: VoiceClone) => void
onClose: () => void
}
/**
* 克隆提交流程 Hook
* 封装上传 + 克隆 + 完成的三阶段流程
*/
export function useCloneSubmit({
voiceName,
voiceDescription,
selectedFile,
recordedBlob,
setPhase,
setErrorMessage,
validateForm,
onSuccess,
onClose,
}: UseCloneSubmitOptions) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 组件卸载时清理定时器 */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const handleSubmit = useCallback(async () => {
const formError = validateForm()
if (formError) {
setErrorMessage(formError)
return
}
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
const formData = new FormData()
formData.append("file", fileToUpload)
const uploadResult = await uploadAsset(formData)
// 阶段 2:克隆
setPhase("cloning")
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
})
// 阶段 3:完成
setPhase("done")
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result))
onClose()
}, 2000)
} catch (err) {
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
}, [
validateForm,
selectedFile,
recordedBlob,
voiceName,
voiceDescription,
setPhase,
setErrorMessage,
onSuccess,
onClose,
])
return { handleSubmit }
}
+36 -184
View File
@@ -1,116 +1,35 @@
/**
* 查重上传页面 — V21 设计系统
* 左右分栏:拖拽上传区 + 格式说明
* 零 antd 依赖
*/
import React, { useState, useRef, useCallback } from "react"
import { useMutation } from "@tanstack/react-query"
import { Button, Card, Tag } from "@/components/ui"
import { uploadForDuplication } from "@/api/duplication"
import React from "react"
import { useNavigate } from "react-router-dom"
import "./duplication.css"
import { Card } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
/** 支持的视频格式 */
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
/** 最大文件大小:2GB */
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
/** 简易 toast */
interface ToastState {
message: string
type: "success" | "error" | "warning"
}
import UploadZone from "./duplication-upload/UploadZone"
import UploadActions from "./duplication-upload/UploadActions"
import UploadProgress from "./duplication-upload/UploadProgress"
import UploadResultPanel from "./duplication-upload/UploadResultPanel"
import InfoSidebar from "./duplication-upload/InfoSidebar"
import { useDuplicationUpload } from "./duplication-upload/useDuplicationUpload"
import { ACCEPT_FORMATS } from "./duplication-upload/constants"
import "./duplication.css"
const DuplicationUpload: React.FC = () => {
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadResult, setUploadResult] = useState<{
id: string
message: string
} | null>(null)
const [toast, setToast] = useState<ToastState | null>(null)
/** 显示 toast */
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
// 上传查重 mutation
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadForDuplication(file),
onSuccess: (data) => {
setUploading(false)
setUploadResult({ id: data.id, message: data.message })
showToast("查重任务已提交", "success")
},
onError: () => {
setUploading(false)
showToast("上传失败,请重试", "error")
},
})
/** 校验并上传文件 */
const handleFile = useCallback(
(file: File) => {
if (file.size > MAX_FILE_SIZE) {
showToast("文件大小不能超过 2GB", "error")
return
}
const ext = file.name.toLowerCase().split(".").pop()
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
if (!allowedExts.includes(ext || "")) {
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
return
}
setUploading(true)
setUploadResult(null)
uploadMutation.mutate(file)
},
[uploadMutation, showToast],
)
/** 拖拽事件 */
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragging(true)
}, [])
const handleDragLeave = useCallback(() => {
setDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
},
[handleFile],
)
/** 点击选择文件 */
const handleSelectFile = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
// 重置 input 以便重复选择同一文件
e.target.value = ""
}
/** 重置状态 */
const handleReset = () => {
setUploadResult(null)
setUploading(false)
}
const {
fileInputRef,
dragging,
uploading,
uploadResult,
toast,
handleDragOver,
handleDragLeave,
handleDrop,
handleSelectFile,
handleFileChange,
handleReset,
} = useDuplicationUpload()
return (
<div className="dup-page">
@@ -125,25 +44,14 @@ const DuplicationUpload: React.FC = () => {
<div className="dup-upload-grid">
{/* 左侧:上传区域 */}
<Card>
{/* 拖拽上传区 */}
<div
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
<UploadZone
dragging={dragging}
uploading={uploading}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={uploading ? undefined : handleSelectFile}
>
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
<p> MP4AVIMOVMKV 2GB</p>
<div className="dup-upload-formats">
{FORMAT_LIST.map((fmt) => (
<Tag key={fmt} variant="info">
{fmt}
</Tag>
))}
</div>
</div>
onClick={handleSelectFile}
/>
{/* 隐藏的文件 input */}
<input
@@ -154,77 +62,21 @@ const DuplicationUpload: React.FC = () => {
onChange={handleFileChange}
/>
{/* 上传按钮 */}
<div className="dup-upload-actions">
<Button
buttonType="primary"
buttonSize="md"
onClick={handleSelectFile}
disabled={uploading}
>
📂
</Button>
</div>
<UploadActions uploading={uploading} onSelectFile={handleSelectFile} />
{/* 上传进度 */}
{uploading && (
<div className="dup-progress">
<div className="dup-progress-circle">
<span className="dup-progress-icon"></span>
<span className="dup-progress-text">...</span>
</div>
<p>...</p>
</div>
)}
{uploading && <UploadProgress />}
{/* 上传结果 */}
{uploadResult && !uploading && (
<div className="dup-result">
<div className="dup-result-icon"></div>
<h3></h3>
<p>{uploadResult.message}</p>
<div className="dup-result-actions">
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/app/duplication/results")}
>
</Button>
<Button buttonType="secondary" buttonSize="md" onClick={handleReset}>
</Button>
</div>
</div>
<UploadResultPanel
result={uploadResult}
onViewResult={() => navigate("/app/duplication/results")}
onReset={handleReset}
/>
)}
</Card>
{/* 右侧:格式说明 + 提示 */}
<div className="dup-info-card">
<h3>📋 </h3>
<ul className="dup-info-list">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<h3 style={{ marginTop: 24 }}>🎬 </h3>
<div className="dup-format-tags">
{FORMAT_LIST.map((fmt) => (
<Tag key={fmt} variant="info">
{fmt}
</Tag>
))}
</div>
<h3 style={{ marginTop: 24 }}>💡 </h3>
<ul className="dup-info-list">
<li> 2GB</li>
<li> 60 </li>
<li></li>
</ul>
</div>
<InfoSidebar />
</div>
</div>
)
+7 -1
View File
@@ -51,7 +51,13 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
{ key: "high", label: "高风险" },
]
/** Toast 类型 */
/** 支持的视频格式 */
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
/** 最大文件大小:2GB */
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
/** 简易 toast */
export interface ToastState {
message: string
type: "success" | "error" | "warning"
@@ -0,0 +1,36 @@
import React from "react"
import { Tag } from "@/components/ui"
import { FORMAT_LIST } from "./constants"
/** 右侧说明卡 */
const InfoSidebar: React.FC = () => {
return (
<div className="dup-info-card">
<h3>📋 </h3>
<ul className="dup-info-list">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<h3 style={{ marginTop: 24 }}>🎬 </h3>
<div className="dup-format-tags">
{FORMAT_LIST.map((fmt) => (
<Tag key={fmt} variant="info">
{fmt}
</Tag>
))}
</div>
<h3 style={{ marginTop: 24 }}>💡 </h3>
<ul className="dup-info-list">
<li> 2GB</li>
<li> 60 </li>
<li></li>
</ul>
</div>
)
}
export default InfoSidebar
@@ -0,0 +1,20 @@
import React from "react"
import { Button } from "@/components/ui"
interface UploadActionsProps {
uploading: boolean
onSelectFile: () => void
}
/** 上传按钮区 */
const UploadActions: React.FC<UploadActionsProps> = ({ uploading, onSelectFile }) => {
return (
<div className="dup-upload-actions">
<Button buttonType="primary" buttonSize="md" onClick={onSelectFile} disabled={uploading}>
📂
</Button>
</div>
)
}
export default UploadActions
@@ -0,0 +1,16 @@
import React from "react"
/** 上传进度展示 */
const UploadProgress: React.FC = () => {
return (
<div className="dup-progress">
<div className="dup-progress-circle">
<span className="dup-progress-icon"></span>
<span className="dup-progress-text">...</span>
</div>
<p>...</p>
</div>
)
}
export default UploadProgress
@@ -0,0 +1,30 @@
import React from "react"
import { Button } from "@/components/ui"
import type { UploadResult } from "./constants"
interface UploadResultPanelProps {
result: UploadResult
onViewResult: () => void
onReset: () => void
}
/** 上传结果展示 */
const UploadResultPanel: React.FC<UploadResultPanelProps> = ({ result, onViewResult, onReset }) => {
return (
<div className="dup-result">
<div className="dup-result-icon"></div>
<h3></h3>
<p>{result.message}</p>
<div className="dup-result-actions">
<Button buttonType="primary" buttonSize="md" onClick={onViewResult}>
</Button>
<Button buttonType="secondary" buttonSize="md" onClick={onReset}>
</Button>
</div>
</div>
)
}
export default UploadResultPanel
@@ -0,0 +1,45 @@
import React from "react"
import { Tag } from "@/components/ui"
import { FORMAT_LIST } from "./constants"
interface UploadZoneProps {
dragging: boolean
uploading: boolean
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent) => void
onClick: () => void
}
/** 拖拽上传区 */
const UploadZone: React.FC<UploadZoneProps> = ({
dragging,
uploading,
onDragOver,
onDragLeave,
onDrop,
onClick,
}) => {
return (
<div
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={uploading ? undefined : onClick}
>
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
<p> MP4AVIMOVMKV 2GB</p>
<div className="dup-upload-formats">
{FORMAT_LIST.map((fmt) => (
<Tag key={fmt} variant="info">
{fmt}
</Tag>
))}
</div>
</div>
)
}
export default UploadZone
@@ -0,0 +1,17 @@
/** 支持的视频格式 */
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
/** 最大文件大小:2GB */
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
/** 简易 toast */
export interface ToastState {
message: string
type: "success" | "error" | "warning"
}
/** 上传结果 */
export interface UploadResult {
id: string
message: string
}
@@ -0,0 +1,115 @@
import { useState, useRef, useCallback } from "react"
import { useMutation } from "@tanstack/react-query"
import { uploadForDuplication } from "@/api/duplication"
import { ACCEPT_FORMATS, FORMAT_LIST, MAX_FILE_SIZE } from "./constants"
import type { ToastState, UploadResult } from "./constants"
/**
* 查重上传逻辑 Hook
* 封装文件校验、上传 mutation、toast 提示
*/
export function useDuplicationUpload() {
const fileInputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null)
const [toast, setToast] = useState<ToastState | null>(null)
/** 显示 toast */
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
// 上传查重 mutation
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadForDuplication(file),
onSuccess: (data) => {
setUploading(false)
setUploadResult({ id: data.id, message: data.message })
showToast("查重任务已提交", "success")
},
onError: () => {
setUploading(false)
showToast("上传失败,请重试", "error")
},
})
/** 校验并上传文件 */
const handleFile = useCallback(
(file: File) => {
if (file.size > MAX_FILE_SIZE) {
showToast("文件大小不能超过 2GB", "error")
return
}
const ext = file.name.toLowerCase().split(".").pop()
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
if (!allowedExts.includes(ext || "")) {
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
return
}
setUploading(true)
setUploadResult(null)
uploadMutation.mutate(file)
},
[uploadMutation, showToast],
)
/** 拖拽事件 */
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragging(true)
}, [])
const handleDragLeave = useCallback(() => {
setDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
},
[handleFile],
)
/** 点击选择文件 */
const handleSelectFile = useCallback(() => {
fileInputRef.current?.click()
}, [])
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
// 重置 input 以便重复选择同一文件
e.target.value = ""
},
[handleFile],
)
/** 重置状态 */
const handleReset = useCallback(() => {
setUploadResult(null)
setUploading(false)
}, [])
return {
// refs
fileInputRef,
// 状态
dragging,
uploading,
uploadResult,
toast,
// 事件
handleDragOver,
handleDragLeave,
handleDrop,
handleSelectFile,
handleFileChange,
handleReset,
}
}
+24 -130
View File
@@ -2,19 +2,14 @@
* Step 7 确认生成组件
*/
import React from "react"
import {
LoadingOutlined,
CheckCircleFilled,
CloseCircleOutlined,
MinusOutlined,
PlusOutlined,
} from "@ant-design/icons"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { useStep7Generate } from "../hooks/useStep7Generate"
import SummaryCard from "./step7-confirm/SummaryCard"
import GenerationStatus from "./step7-confirm/GenerationStatus"
interface Step7ConfirmGenerateProps {
templates: EditingTemplate[]
@@ -64,129 +59,28 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
return (
<div className="xx-form-section">
<h3> </h3>
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{templateName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{materialSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{voiceName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={handleDecrement}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={handleIncrement}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
{/* 生成进度 / 结果反馈 */}
{(generating || generated || generateError) && (
<div style={{ marginTop: 16 }}>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub"> 1-2 </div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title"></div>
<div className="xx-gen-success-sub">
{generatedVideos.length}
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleScrollToPreview}
>
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
<div className="xx-gen-error-msg">
{typeof generateError === "string"
? generateError
: JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
</button>
</div>
</div>
)}
</div>
)}
<SummaryCard
templateName={templateName}
materialSummary={materialSummary}
title={title}
voiceName={voiceName}
coverSummary={coverSummary}
generateCount={generateCount}
generating={generating}
onDecrement={handleDecrement}
onIncrement={handleIncrement}
/>
<GenerationStatus
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
getGenerationPhase={getGenerationPhase}
onScrollToPreview={handleScrollToPreview}
onRetry={onRetry}
onDismissError={onDismissError}
/>
</div>
)
}
@@ -0,0 +1,106 @@
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
interface GenerationStatusProps {
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
getGenerationPhase: (progress: number) => { icon: string; label: string }
onScrollToPreview: () => void
onRetry: () => void
onDismissError: () => void
}
const GenerationStatus: React.FC<GenerationStatusProps> = ({
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
onScrollToPreview,
onRetry,
onDismissError,
}) => {
if (!generating && !generated && !generateError) return null
return (
<div style={{ marginTop: 16 }}>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub"> 1-2 </div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title"></div>
<div className="xx-gen-success-sub">
{generatedVideos.length}
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onScrollToPreview}
>
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
<div className="xx-gen-error-msg">
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
</button>
</div>
</div>
)}
</div>
)
}
export default GenerationStatus
@@ -0,0 +1,76 @@
import React from "react"
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
interface SummaryCardProps {
templateName: string
materialSummary: string
title: string
voiceName: string
coverSummary: string
generateCount: number
generating: boolean
onDecrement: () => void
onIncrement: () => void
}
const SummaryCard: React.FC<SummaryCardProps> = ({
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generateCount,
generating,
onDecrement,
onIncrement,
}) => {
return (
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{templateName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{materialSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{voiceName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={onDecrement}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={onIncrement}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
)
}
export default SummaryCard
@@ -0,0 +1,41 @@
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { getAssets, getAssetLibraries } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
/**
* 素材库加载 Hook
* 管理素材库列表、当前选中库、素材列表加载
*/
export function useMaterialLibrary() {
/* ── 素材库数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
useEffect(() => {
if (libraries.length > 0 && !selectedLibraryId) {
setSelectedLibraryId(libraries[0].id)
}
}, [libraries, selectedLibraryId])
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
items: AssetItem[]
total: number
}>({
queryKey: ["generate-assets", selectedLibraryId],
queryFn: () => getAssets(selectedLibraryId),
enabled: !!selectedLibraryId,
})
return {
libraries,
selectedLibraryId,
setSelectedLibraryId,
materials,
materialsLoading,
}
}
@@ -0,0 +1,143 @@
import { useState, useCallback, useMemo } from "react"
import { message } from "antd"
import type { AssetItem } from "@/api/assets"
import { SMART_MATCH_REASONS } from "../../constants"
interface SmartMatchedResult {
asset: AssetItem
matchScore: number
matchReason: string
}
interface UseSmartMatchOptions {
materials: { items: AssetItem[]; total: number }
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
}
/**
* 智能素材匹配 Hook
* 封装 AI 匹配、换一批、全选/清空等逻辑
*/
export function useSmartMatch({
materials,
smartSelectedIds,
onSmartSelectedIdsChange,
}: UseSmartMatchOptions) {
const [smartMatchInput, setSmartMatchInput] = useState("")
const [smartMatching, setSmartMatching] = useState(false)
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
const [hasMatched, setHasMatched] = useState(false)
/* ── 智能素材匹配 ── */
const handleSmartMatch = useCallback(async () => {
if (!smartMatchInput.trim()) {
message.warning("请先输入视频内容描述")
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
return
}
setSmartMatching(true)
setHasMatched(true)
// 模拟 AI 匹配延迟
await new Promise((resolve) => setTimeout(resolve, 1500))
// 从素材库中随机选取 5-8 个作为推荐结果
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
matchReason:
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
}))
// 按匹配度从高到低排序
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
// 默认选中匹配度 >= 90 的素材
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
onSmartSelectedIdsChange(
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
)
setSmartMatching(false)
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
const handleToggleSmartSelect = useCallback(
(assetId: string) => {
onSmartSelectedIdsChange(
smartSelectedIds.includes(assetId)
? smartSelectedIds.filter((id) => id !== assetId)
: [...smartSelectedIds, assetId],
)
},
[smartSelectedIds, onSmartSelectedIdsChange],
)
const handleRefreshMatch = useCallback(async () => {
if (materials.items.length <= 5) {
message.info("视频库素材较少,无法换一批")
return
}
setSmartMatching(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const remaining = materials.items.filter(
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
)
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(80 + Math.random() * 19),
matchReason:
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",节奏明快" : ""),
}))
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
onSmartSelectedIdsChange([])
setSmartMatching(false)
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
}, [smartMatchedResults, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
onSmartSelectedIdsChange([])
}, [onSmartSelectedIdsChange])
/* ── 计算已选智能匹配素材的总时长 ── */
const smartSelectedTotalDuration = useMemo(() => {
return smartMatchedResults
.filter((r) => smartSelectedIds.includes(r.asset.id))
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
}, [smartMatchedResults, smartSelectedIds])
return {
smartMatchInput,
setSmartMatchInput,
smartMatching,
smartMatchedResults,
hasMatched,
smartSelectedIds,
handleSmartMatch,
handleToggleSmartSelect,
handleRefreshMatch,
handleSelectAllMatched,
handleClearSmartSelect,
smartSelectedTotalDuration,
}
}
+23 -149
View File
@@ -1,20 +1,11 @@
/**
* Step 2 素材选择 Hook
* 封装素材库加载、手动选择、智能匹配等逻辑
* 组合素材库加载 + 智能匹配两个子 Hook
*/
import { useState, useCallback, useEffect, useMemo } from "react"
import { message } from "antd"
import { useQuery } from "@tanstack/react-query"
import { getAssets, getAssetLibraries } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
import { useCallback } from "react"
import { formatDuration } from "../utils/formatDuration"
import { SMART_MATCH_REASONS } from "../constants"
interface SmartMatchedResult {
asset: AssetItem
matchScore: number
matchReason: string
}
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
interface UseStep2MaterialsProps {
materialMode: "manual" | "auto"
@@ -33,34 +24,14 @@ export function useStep2Materials({
smartSelectedIds,
onSmartSelectedIdsChange,
}: UseStep2MaterialsProps) {
/* ── 素材库数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
useMaterialLibrary()
const smartMatch = useSmartMatch({
materials,
smartSelectedIds,
onSmartSelectedIdsChange,
})
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
useEffect(() => {
if (libraries.length > 0 && !selectedLibraryId) {
setSelectedLibraryId(libraries[0].id)
}
}, [libraries, selectedLibraryId])
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
items: AssetItem[]
total: number
}>({
queryKey: ["generate-assets", selectedLibraryId],
queryFn: () => getAssets(selectedLibraryId),
enabled: !!selectedLibraryId,
})
/* ── 智能素材匹配状态 ── */
const [smartMatchInput, setSmartMatchInput] = useState("")
const [smartMatching, setSmartMatching] = useState(false)
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
const [hasMatched, setHasMatched] = useState(false)
/* ── 手动选择素材 ── */
const handleToggleMaterial = useCallback(
@@ -74,103 +45,6 @@ export function useStep2Materials({
[selectedMaterials, onSelectedMaterialsChange],
)
/* ── 智能素材匹配 ── */
const handleSmartMatch = useCallback(async () => {
if (!smartMatchInput.trim()) {
message.warning("请先输入视频内容描述")
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
return
}
setSmartMatching(true)
setHasMatched(true)
// 模拟 AI 匹配延迟
await new Promise((resolve) => setTimeout(resolve, 1500))
// 从素材库中随机选取 5-8 个作为推荐结果
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
matchReason:
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
}))
// 按匹配度从高到低排序
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
// 默认选中匹配度 >= 90 的素材
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
onSmartSelectedIdsChange(
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
)
setSmartMatching(false)
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
const handleToggleSmartSelect = useCallback(
(assetId: string) => {
onSmartSelectedIdsChange(
smartSelectedIds.includes(assetId)
? smartSelectedIds.filter((id) => id !== assetId)
: [...smartSelectedIds, assetId],
)
},
[smartSelectedIds, onSmartSelectedIdsChange],
)
const handleRefreshMatch = useCallback(async () => {
if (materials.items.length <= 5) {
message.info("视频库素材较少,无法换一批")
return
}
setSmartMatching(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const remaining = materials.items.filter(
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
)
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(80 + Math.random() * 19),
matchReason:
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",节奏明快" : ""),
}))
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
onSmartSelectedIdsChange([])
setSmartMatching(false)
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
}, [smartMatchedResults, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
onSmartSelectedIdsChange([])
}, [onSmartSelectedIdsChange])
/* ── 计算已选智能匹配素材的总时长 ── */
const smartSelectedTotalDuration = useMemo(() => {
return smartMatchedResults
.filter((r) => smartSelectedIds.includes(r.asset.id))
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
}, [smartMatchedResults, smartSelectedIds])
return {
// 素材库
libraries,
@@ -185,18 +59,18 @@ export function useStep2Materials({
selectedMaterials,
handleToggleMaterial,
// 智能匹配
smartMatchInput,
setSmartMatchInput,
smartMatching,
smartMatchedResults,
hasMatched,
smartSelectedIds,
handleSmartMatch,
handleToggleSmartSelect,
handleRefreshMatch,
handleSelectAllMatched,
handleClearSmartSelect,
smartSelectedTotalDuration,
smartMatchInput: smartMatch.smartMatchInput,
setSmartMatchInput: smartMatch.setSmartMatchInput,
smartMatching: smartMatch.smartMatching,
smartMatchedResults: smartMatch.smartMatchedResults,
hasMatched: smartMatch.hasMatched,
smartSelectedIds: smartMatch.smartSelectedIds,
handleSmartMatch: smartMatch.handleSmartMatch,
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
handleRefreshMatch: smartMatch.handleRefreshMatch,
handleSelectAllMatched: smartMatch.handleSelectAllMatched,
handleClearSmartSelect: smartMatch.handleClearSmartSelect,
smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration,
// utils
formatDuration,
}
+20 -136
View File
@@ -1,25 +1,13 @@
import React from "react"
import { Button, Descriptions, Tooltip } from "antd"
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
import type { TemplateItem, TemplateSegment } from "@/api/templates"
import {
gradientForCategory,
getTypeColor,
formatDuration,
formatConfig,
getMaterialTypeLabel,
calcTotalSegmentDuration,
} from "../../utils/templateLibrary"
import { Descriptions } from "antd"
import type { TemplateDetailModalProps } from "./template-detail-modal/types"
import PreviewArea from "./template-detail-modal/PreviewArea"
import SegmentList from "./template-detail-modal/SegmentList"
import StyleConfig from "./template-detail-modal/StyleConfig"
import DetailFooter from "./template-detail-modal/DetailFooter"
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
interface TemplateDetailModalProps {
template: TemplateItem
isFavorite: boolean
onClose: () => void
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
@@ -31,6 +19,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
}) => {
const segments = template.segments ?? []
const totalSegmentDuration = calcTotalSegmentDuration(segments)
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
return (
<div className="xx-template-modal-overlay" onClick={onClose}>
@@ -38,33 +27,8 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
className="xx-template-modal xx-template-modal-wide"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
</button>
<PreviewArea template={template} onClose={onClose} />
{/* 预览区域 */}
<div
className="xx-template-modal-preview"
style={{ background: gradientForCategory(template.category) }}
>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
</div>
{/* 内容区域 */}
<div className="xx-template-modal-content">
{/* 标题行 */}
<div className="xx-template-modal-title-row">
@@ -76,7 +40,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
background: `${getTypeColor(template.category)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
{typeInfo?.icon} {template.category}
</span>
</div>
@@ -123,95 +87,15 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
]}
/>
{/* 素材规则(片段配置) */}
{segments.length > 0 && (
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{segments
.sort((a, b) => a.segment_order - b.segment_order)
.map((seg: TemplateSegment, idx: number) => (
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
<span
className="xx-template-modal-clip-badge"
style={{
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
}}
>
{getMaterialTypeLabel(seg.material_type)}
</span>
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalSegmentDuration))}
</div>
</div>
)}
{/* 样式配置 */}
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.subtitle_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.title_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">BGM </span>
<span className="xx-template-modal-style-value">
{formatConfig(template.bgm_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{template.aspect_ratio ?? "16:9"}
</span>
</div>
</div>
</div>
{/* 统计信息 */}
<div className="xx-template-modal-stats">
<span>使 {template.usage_count ?? 0} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
>
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
</button>
</div>
{/* 操作按钮 */}
<div className="xx-template-modal-actions">
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
</Button>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
使
</Button>
</div>
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
<StyleConfig template={template} />
<DetailFooter
template={template}
isFavorite={isFavorite}
onToggleFavorite={onToggleFavorite}
onUse={onUse}
onCopy={onCopy}
/>
</div>
</div>
</div>
@@ -0,0 +1,46 @@
import React from "react"
import { Button } from "antd"
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
import type { TemplateItem } from "@/api/templates"
interface DetailFooterProps {
template: TemplateItem
isFavorite: boolean
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
/** 底部操作区:统计 + 收藏 + 按钮 */
const DetailFooter: React.FC<DetailFooterProps> = ({
template,
isFavorite,
onToggleFavorite,
onUse,
onCopy,
}) => {
return (
<>
<div className="xx-template-modal-stats">
<span>使 {template.usage_count ?? 0} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
>
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
</button>
</div>
<div className="xx-template-modal-actions">
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
</Button>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
使
</Button>
</div>
</>
)
}
export default DetailFooter
@@ -0,0 +1,39 @@
import React from "react"
import type { TemplateItem } from "@/api/templates"
import { gradientForCategory } from "../../../utils/templateLibrary"
import { TEMPLATE_TYPES } from "../../../constants/templateLibrary"
interface PreviewAreaProps {
template: TemplateItem
onClose: () => void
}
/** 预览区域 */
const PreviewArea: React.FC<PreviewAreaProps> = ({ template, onClose }) => {
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
return (
<div
className="xx-template-modal-preview"
style={{ background: gradientForCategory(template.category) }}
>
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
</button>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">{typeInfo?.icon ?? "📋"}</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
</div>
)
}
export default PreviewArea
@@ -0,0 +1,53 @@
import React from "react"
import { Tooltip } from "antd"
import type { TemplateSegment } from "@/api/templates"
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
interface SegmentListProps {
segments: TemplateSegment[]
totalDuration: number
}
/** 素材规则 / 片段列表 */
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
if (segments.length === 0) return null
return (
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{segments
.sort((a, b) => a.segment_order - b.segment_order)
.map((seg, idx) => (
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
<span
className="xx-template-modal-clip-badge"
style={{
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
}}
>
{getMaterialTypeLabel(seg.material_type)}
</span>
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalDuration))}
</div>
</div>
)
}
export default SegmentList
@@ -0,0 +1,33 @@
import React from "react"
import type { TemplateItem } from "@/api/templates"
import { formatConfig } from "../../../utils/templateLibrary"
interface StyleConfigProps {
template: TemplateItem
}
/** 样式配置网格 */
const StyleConfig: React.FC<StyleConfigProps> = ({ template }) => {
const items = [
{ label: "字幕样式", value: formatConfig(template.subtitle_config) },
{ label: "标题样式", value: formatConfig(template.title_config) },
{ label: "BGM 配置", value: formatConfig(template.bgm_config) },
{ label: "视频比例", value: template.aspect_ratio ?? "16:9" },
]
return (
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
{items.map((item) => (
<div key={item.label} className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">{item.label}</span>
<span className="xx-template-modal-style-value">{item.value}</span>
</div>
))}
</div>
</div>
)
}
export default StyleConfig
@@ -0,0 +1,6 @@
export { TemplateDetailModal } from "../TemplateDetailModal"
export * from "./types"
export { default as PreviewArea } from "./PreviewArea"
export { default as SegmentList } from "./SegmentList"
export { default as StyleConfig } from "./StyleConfig"
export { default as DetailFooter } from "./DetailFooter"
@@ -0,0 +1,10 @@
import type { TemplateItem } from "@/api/templates"
export interface TemplateDetailModalProps {
template: TemplateItem
isFavorite: boolean
onClose: () => void
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
+5 -81
View File
@@ -1,11 +1,10 @@
import React, { useState, useRef } from "react"
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
import React, { useState } from "react"
import { Button, Input } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial } from "../types"
import { GENDER_OPTIONS } from "../constants"
import { genderClass, formatFileSize } from "../utils/format"
import TagSelector from "./TagSelector"
import FileUploadField from "./material-form/FileUploadField"
import GenderSelector from "./material-form/GenderSelector"
export interface MaterialFormProps {
initial?: VoiceMaterial
@@ -33,7 +32,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
const [file, setFile] = useState<File | undefined>(undefined)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleSubmit = () => {
if (!name.trim()) return
@@ -53,65 +51,10 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
return (
<div className="vmat-form">
{/* 音频文件上传(编辑模式不显示) */}
{!initial && (
<div className="vmat-form-field">
<label className="vmat-form-label"> *</label>
<div
className="vmat-upload-zone"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f?.type.startsWith("audio/")) setFile(f)
}}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) setFile(f)
}}
/>
{file ? (
<div className="vmat-upload-selected">
<SoundOutlined className="vmat-upload-icon" />
<span className="vmat-upload-filename">{file.name}</span>
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
<button
type="button"
className="vmat-upload-clear"
onClick={(e) => {
e.stopPropagation()
setFile(undefined)
}}
>
<CloseOutlined />
</button>
</div>
) : (
<div className="vmat-upload-placeholder">
<UploadOutlined className="vmat-upload-icon" />
<p></p>
<span> MP3WAVAACFLAC </span>
</div>
)}
</div>
{/* 上传进度条 */}
{uploadProgress !== null && uploadProgress !== undefined && (
<div className="vmat-upload-progress">
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
</div>
)}
</div>
<FileUploadField file={file} onChange={setFile} uploadProgress={uploadProgress} />
)}
{/* 名称 */}
<div className="vmat-form-field">
<label className="vmat-form-label"> *</label>
<Input
@@ -122,7 +65,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/>
</div>
{/* 音色描述 */}
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<Input.TextArea
@@ -134,25 +76,8 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/>
</div>
{/* 性别 */}
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<div className="vmat-gender-group">
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`vmat-gender-btn${gender === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
onClick={() => setGender(opt.value)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
<GenderSelector value={gender} onChange={setGender} />
{/* 风格标签 */}
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<TagSelector
@@ -164,7 +89,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/>
</div>
{/* 操作按钮 */}
<div className="vmat-form-actions">
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
+16 -72
View File
@@ -1,7 +1,8 @@
import React, { useState, useRef, useCallback, useMemo } from "react"
import React from "react"
import { CheckOutlined } from "@ant-design/icons"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import type { TagItem } from "@/api/tags"
import { useTagInput } from "./tag-selector/useTagInput"
export interface TagSelectorProps {
/** 已选标签 ID 列表 */
@@ -24,77 +25,22 @@ const TagSelector: React.FC<TagSelectorProps> = ({
onCreateTag,
placeholder = "输入标签后回车添加",
}) => {
const [inputVal, setInputVal] = useState("")
const [showSuggestions, setShowSuggestions] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
/** 按名称查找已有标签(大小写不敏感) */
const findTagByName = useCallback(
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
[tags],
)
/** 去重添加标签(按 ID */
const addTagId = useCallback(
(tagId: string) => {
if (value.includes(tagId)) return
onChange([...value, tagId])
setInputVal("")
setShowSuggestions(false)
},
[value, onChange],
)
/** 输入自定义标签名:若已存在则直接选,否则创建新标签 */
const addTagByName = useCallback(
async (name: string) => {
const trimmed = name.trim()
if (!trimmed) return
const existing = findTagByName(trimmed)
if (existing) {
addTagId(existing.id)
} else {
try {
const created = await onCreateTag(trimmed)
addTagId(created.id)
} catch {
/* 创建失败静默忽略 */
}
}
},
[findTagByName, addTagId, onCreateTag],
)
const removeTagId = useCallback(
(tagId: string) => {
onChange(value.filter((t) => t !== tagId))
},
[value, onChange],
)
/** 输入补全建议(排除已选) */
const suggestions = useMemo(() => {
if (!inputVal.trim()) return []
const lower = inputVal.toLowerCase()
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
}, [inputVal, tags, value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (suggestions.length > 0) {
addTagId(suggestions[0].id)
} else {
addTagByName(inputVal)
}
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
removeTagId(value[value.length - 1])
}
}
const {
inputVal,
setInputVal,
showSuggestions,
setShowSuggestions,
inputRef,
suggestions,
addTagId,
removeTagId,
handleKeyDown,
focus,
} = useTagInput({ value, onChange, tags, onCreateTag })
return (
<div className="vmat-tag-selector-wrapper">
<div className="vmat-tag-selector" onClick={() => inputRef.current?.focus()}>
<div className="vmat-tag-selector" onClick={focus}>
{value.map((tagId) => (
<Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}>
{tagMap.get(tagId)?.name ?? tagId}
@@ -115,7 +61,6 @@ const TagSelector: React.FC<TagSelectorProps> = ({
/>
</div>
{/* 自动补全下拉 */}
{showSuggestions && suggestions.length > 0 && (
<div className="vmat-tag-suggestions">
{suggestions.slice(0, 6).map((tag) => (
@@ -134,7 +79,6 @@ const TagSelector: React.FC<TagSelectorProps> = ({
</div>
)}
{/* 已有标签快捷选择 */}
{tags.length > 0 && (
<div className="vmat-tag-selector-presets">
{tags.map((tag) => {
+35 -208
View File
@@ -1,45 +1,12 @@
import React, { useRef } from "react"
import {
AudioOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
EditOutlined,
DeleteOutlined,
CheckOutlined,
SoundOutlined,
MutedOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceMaterial } from "../types"
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants"
import {
genderClass,
genderIcon,
genderLabel,
formatDuration,
formatFileSize,
formatDate,
} from "../utils/format"
export interface VoiceCardProps {
material: VoiceMaterial
isPlaying: boolean
currentTime: number
isSelected: boolean
batchMode: boolean
volume: number
tagMap: Map<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
import React from "react"
import { type VoiceCardProps } from "./voice-material-card/types"
import BatchCheckbox from "./voice-material-card/BatchCheckbox"
import CardActions from "./voice-material-card/CardActions"
import CardHeader from "./voice-material-card/CardHeader"
import CardTags from "./voice-material-card/CardTags"
import CardMeta from "./voice-material-card/CardMeta"
import CardPlayer from "./voice-material-card/CardPlayer"
import { genderClass } from "../utils/format"
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
material,
@@ -58,29 +25,6 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * material.duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(material.id)
@@ -92,154 +36,37 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
onClick={handleCardClick}
>
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && (
<div
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(material.id)
}}
>
{isSelected && <CheckOutlined />}
</div>
)}
<BatchCheckbox
isSelected={isSelected}
visible={batchMode || isSelected}
onToggle={() => onToggleSelect(material.id)}
/>
<CardActions onEdit={onEdit} onDelete={onDelete} />
<CardHeader material={material} />
{/* 操作按钮 */}
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
{/* 头部:图标 + 名称 + 性别 */}
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
{/* 描述 */}
{material.description && <p className="vmat-card-desc">{material.description}</p>}
{/* 标签 */}
<div className="vmat-card-tags">
{material.tagIds.length === 0 ? (
<span
className="vmat-tag-empty"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
</span>
) : (
<>
{material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{material.tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</>
)}
</div>
{/* 元信息 */}
<div className="vmat-card-meta">
<span>{formatDuration(material.duration)}</span>
<span>{formatFileSize(material.fileSize)}</span>
<span>{formatDate(material.createdAt)}</span>
</div>
{/* 播放控制 */}
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!material.fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<CardTags tagIds={material.tagIds} tagMap={tagMap} onEdit={onEdit} />
<CardMeta
duration={material.duration}
fileSize={material.fileSize}
createdAt={material.createdAt}
/>
<CardPlayer
isPlaying={isPlaying}
currentTime={currentTime}
duration={material.duration}
volume={volume}
fileUrl={material.fileUrl}
onPlay={onPlay}
onPause={onPause}
onSeek={onSeek}
onVolumeChange={onVolumeChange}
onToggleMute={onToggleMute}
/>
</div>
)
}
export default VoiceMaterialCard
export type { VoiceCardProps }
+9 -57
View File
@@ -1,4 +1,4 @@
import React, { useRef } from "react"
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
@@ -6,11 +6,8 @@ import {
DeleteOutlined,
CheckOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceMaterial } from "../types"
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../constants"
import {
genderClass,
genderIcon,
@@ -18,6 +15,8 @@ import {
formatDuration,
formatFileSize,
} from "../utils/format"
import { useRowProgress } from "./voice-material-row/useRowProgress"
import TagDisplay from "./voice-material-row/TagDisplay"
export interface VoiceRowProps {
material: VoiceMaterial
@@ -48,26 +47,10 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
onDelete,
onToggleSelect,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * material.duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const { progressRef, handleMouseDown } = useRowProgress({
duration: material.duration,
onSeek,
})
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
@@ -75,7 +58,6 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
<div
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
>
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && (
<div
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
@@ -88,7 +70,6 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
</div>
)}
{/* 播放按钮 */}
<button
type="button"
className="vmat-row-play"
@@ -101,60 +82,31 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 名称 + 描述 */}
<div className="vmat-row-info">
<h4 className="vmat-row-name">{material.name}</h4>
{material.description && <p className="vmat-row-desc">{material.description}</p>}
</div>
{/* 性别 */}
<span className={`vmat-row-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
{/* 标签 */}
<div className="vmat-row-tags">
{material.tagIds.length === 0 ? (
<span className="vmat-tag-empty" onClick={() => onEdit()}>
</span>
) : (
<>
{material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{material.tagIds.length > MAX_ROW_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_ROW_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_ROW_TAGS}</Tag>
</Tooltip>
)}
</>
)}
<TagDisplay tagIds={material.tagIds} tagMap={tagMap} onAddTag={() => onEdit()} />
</div>
{/* 进度条(可拖拽) */}
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleProgressMouseDown}>
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleMouseDown}>
<div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
{/* 时长 */}
<span className="vmat-row-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span>
{/* 文件大小 */}
<span className="vmat-row-size">{formatFileSize(material.fileSize)}</span>
{/* 操作 */}
<div className="vmat-row-actions">
<button
type="button"
@@ -0,0 +1,73 @@
import React, { useRef } from "react"
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
import { formatFileSize } from "../../utils/format"
interface FileUploadFieldProps {
file: File | undefined
onChange: (file: File | undefined) => void
uploadProgress?: number | null
}
const FileUploadField: React.FC<FileUploadFieldProps> = ({ file, onChange, uploadProgress }) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f?.type.startsWith("audio/")) onChange(f)
}
return (
<div className="vmat-form-field">
<label className="vmat-form-label"> *</label>
<div
className="vmat-upload-zone"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onChange(f)
}}
/>
{file ? (
<div className="vmat-upload-selected">
<SoundOutlined className="vmat-upload-icon" />
<span className="vmat-upload-filename">{file.name}</span>
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
<button
type="button"
className="vmat-upload-clear"
onClick={(e) => {
e.stopPropagation()
onChange(undefined)
}}
>
<CloseOutlined />
</button>
</div>
) : (
<div className="vmat-upload-placeholder">
<UploadOutlined className="vmat-upload-icon" />
<p></p>
<span> MP3WAVAACFLAC </span>
</div>
)}
</div>
{uploadProgress !== null && uploadProgress !== undefined && (
<div className="vmat-upload-progress">
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
</div>
)}
</div>
)
}
export default FileUploadField
@@ -0,0 +1,32 @@
import React from "react"
import type { VoiceGender } from "../../types"
import { GENDER_OPTIONS } from "../../constants"
import { genderClass } from "../../utils/format"
interface GenderSelectorProps {
value: VoiceGender
onChange: (value: VoiceGender) => void
}
const GenderSelector: React.FC<GenderSelectorProps> = ({ value, onChange }) => {
return (
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<div className="vmat-gender-group">
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`vmat-gender-btn${value === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
onClick={() => onChange(opt.value)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
)
}
export default GenderSelector
@@ -0,0 +1,90 @@
import { useState, useRef, useCallback, useMemo } from "react"
import type { TagItem } from "@/api/tags"
interface UseTagInputOptions {
value: string[]
onChange: (tagIds: string[]) => void
tags: TagItem[]
onCreateTag: (name: string) => Promise<TagItem>
}
export function useTagInput({ value, onChange, tags, onCreateTag }: UseTagInputOptions) {
const [inputVal, setInputVal] = useState("")
const [showSuggestions, setShowSuggestions] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const findTagByName = useCallback(
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
[tags],
)
const addTagId = useCallback(
(tagId: string) => {
if (value.includes(tagId)) return
onChange([...value, tagId])
setInputVal("")
setShowSuggestions(false)
},
[value, onChange],
)
const addTagByName = useCallback(
async (name: string) => {
const trimmed = name.trim()
if (!trimmed) return
const existing = findTagByName(trimmed)
if (existing) {
addTagId(existing.id)
} else {
try {
const created = await onCreateTag(trimmed)
addTagId(created.id)
} catch {
/* 创建失败静默忽略 */
}
}
},
[findTagByName, addTagId, onCreateTag],
)
const removeTagId = useCallback(
(tagId: string) => {
onChange(value.filter((t) => t !== tagId))
},
[value, onChange],
)
const suggestions = useMemo(() => {
if (!inputVal.trim()) return []
const lower = inputVal.toLowerCase()
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
}, [inputVal, tags, value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (suggestions.length > 0) {
addTagId(suggestions[0].id)
} else {
addTagByName(inputVal)
}
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
removeTagId(value[value.length - 1])
}
}
const focus = () => inputRef.current?.focus()
return {
inputVal,
setInputVal,
showSuggestions,
setShowSuggestions,
inputRef,
suggestions,
addTagId,
removeTagId,
handleKeyDown,
focus,
}
}
@@ -0,0 +1,26 @@
import React from "react"
import { CheckOutlined } from "@ant-design/icons"
interface BatchCheckboxProps {
isSelected: boolean
visible: boolean
onToggle: () => void
}
/** 批量选择 checkbox */
const BatchCheckbox: React.FC<BatchCheckboxProps> = ({ isSelected, visible, onToggle }) => {
if (!visible) return null
return (
<div
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{isSelected && <CheckOutlined />}
</div>
)
}
export default BatchCheckbox
@@ -0,0 +1,39 @@
import React from "react"
import { EditOutlined, DeleteOutlined } from "@ant-design/icons"
interface CardActionsProps {
onEdit: () => void
onDelete: () => void
}
/** 卡片操作按钮:编辑 / 删除 */
const CardActions: React.FC<CardActionsProps> = ({ onEdit, onDelete }) => {
return (
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
)
}
export default CardActions
@@ -0,0 +1,30 @@
import React from "react"
import { AudioOutlined } from "@ant-design/icons"
import { type VoiceMaterial } from "../../types"
import { genderClass, genderIcon, genderLabel } from "../../utils/format"
interface CardHeaderProps {
material: VoiceMaterial
}
/** 卡片头部:头像 + 名称 + 性别标签 */
const CardHeader: React.FC<CardHeaderProps> = ({ material }) => {
return (
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
)
}
export default CardHeader
@@ -0,0 +1,21 @@
import React from "react"
import { formatDuration, formatFileSize, formatDate } from "../../utils/format"
interface CardMetaProps {
duration: number
fileSize: number
createdAt: string
}
/** 元信息:时长 / 文件大小 / 创建日期 */
const CardMeta: React.FC<CardMetaProps> = ({ duration, fileSize, createdAt }) => {
return (
<div className="vmat-card-meta">
<span>{formatDuration(duration)}</span>
<span>{formatFileSize(fileSize)}</span>
<span>{formatDate(createdAt)}</span>
</div>
)
}
export default CardMeta
@@ -0,0 +1,110 @@
import React, { useRef } from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
SoundOutlined,
MutedOutlined,
} from "@ant-design/icons"
import { formatDuration } from "../../utils/format"
interface CardPlayerProps {
isPlaying: boolean
currentTime: number
duration: number
volume: number
fileUrl?: string
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
/** 播放控制区:播放按钮 + 进度条 + 时间 + 音量 */
const CardPlayer: React.FC<CardPlayerProps> = ({
isPlaying,
currentTime,
duration,
volume,
fileUrl,
onPlay,
onPause,
onSeek,
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)
}
export default CardPlayer
@@ -0,0 +1,52 @@
import React from "react"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../../constants"
interface CardTagsProps {
tagIds: string[]
tagMap: Map<string, TagItem>
onEdit: () => void
}
/** 标签展示区 */
const CardTags: React.FC<CardTagsProps> = ({ tagIds, tagMap, onEdit }) => {
if (tagIds.length === 0) {
return (
<div className="vmat-card-tags">
<span
className="vmat-tag-empty"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
</span>
</div>
)
}
return (
<div className="vmat-card-tags">
{tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</div>
)
}
export default CardTags
@@ -0,0 +1,8 @@
export { default } from "../VoiceMaterialCard"
export * from "./types"
export { default as CardHeader } from "./CardHeader"
export { default as CardTags } from "./CardTags"
export { default as CardMeta } from "./CardMeta"
export { default as CardPlayer } from "./CardPlayer"
export { default as CardActions } from "./CardActions"
export { default as BatchCheckbox } from "./BatchCheckbox"
@@ -0,0 +1,21 @@
import React from "react"
import { type VoiceMaterial } from "../../types"
import { type TagItem } from "@/api/tags"
export interface VoiceCardProps {
material: VoiceMaterial
isPlaying: boolean
currentTime: number
isSelected: boolean
batchMode: boolean
volume: number
tagMap: Map<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
@@ -0,0 +1,41 @@
import React from "react"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../../constants"
interface TagDisplayProps {
tagIds: string[]
tagMap: Map<string, TagItem>
onAddTag?: () => void
}
const TagDisplay: React.FC<TagDisplayProps> = ({ tagIds, tagMap, onAddTag }) => {
if (tagIds.length === 0) {
return (
<span className="vmat-tag-empty" onClick={onAddTag}>
</span>
)
}
const visible = tagIds.slice(0, MAX_ROW_TAGS)
const overflow = tagIds.slice(MAX_ROW_TAGS)
return (
<>
{visible.map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{overflow.length > 0 && (
<Tooltip title={overflow.map((id) => tagMap.get(id)?.name ?? id).join("、")}>
<Tag className="vmat-tag-overflow">+{overflow.length}</Tag>
</Tooltip>
)}
</>
)
}
export default TagDisplay
@@ -0,0 +1,38 @@
import { useRef, useCallback } from "react"
interface UseRowProgressOptions {
duration: number
onSeek: (time: number) => void
}
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
const progressRef = useRef<HTMLDivElement>(null)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
},
[duration, onSeek],
)
return { progressRef, handleMouseDown }
}
@@ -0,0 +1,38 @@
import { useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { deleteAsset } from "@/api/assets"
import { type VoiceMaterial } from "../../../types"
interface UseVoiceDeleteOptions {
materials: VoiceMaterial[]
}
/**
* 配音素材删除 Hook
*/
export function useVoiceDelete({ materials }: UseVoiceDeleteOptions) {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (assetId: string) => deleteAsset(assetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
},
})
const handleDelete = useCallback(
(id: string, onBeforeDelete?: () => void) => {
const material = materials.find((m) => m.id === id)
if (!material) return
if (onBeforeDelete) onBeforeDelete()
deleteMutation.mutate(id)
},
[materials, deleteMutation],
)
return {
isDeleting: deleteMutation.isPending,
deleteMutation,
handleDelete,
}
}
@@ -0,0 +1,78 @@
import { useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { updateAsset } from "@/api/assets"
import { tagAsset, untagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
interface UseVoiceEditOptions {
materials: VoiceMaterial[]
}
/**
* 配音素材编辑 Hook
* 封装编辑流程:更新基础信息 + 同步标签差异
*/
export function useVoiceEdit({ materials }: UseVoiceEditOptions) {
const queryClient = useQueryClient()
const editMutation = useMutation({
mutationFn: async (data: {
id: string
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
// 1. 更新基础信息
await updateAsset(data.id, {
name: data.name,
metadata: buildMetadata({
gender: data.gender,
description: data.description,
}),
})
// 2. 对比标签差异,调用 tag/untag API
const currentAsset = materials.find((m) => m.id === data.id)
const oldTagIds = currentAsset?.tagIds ?? []
const newTagIds = data.tagIds
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
if (toAdd.length > 0) {
await tagAsset(data.id, toAdd)
}
for (const tagId of toRemove) {
await untagAsset(data.id, tagId)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
const handleEdit = useCallback(
(
editingMaterial: VoiceMaterial | null,
data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File },
) => {
if (!editingMaterial) return
editMutation.mutate({
id: editingMaterial.id,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
})
},
[editMutation],
)
return {
isEditing: editMutation.isPending,
editMutation,
handleEdit,
}
}
@@ -0,0 +1,111 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
createAsset,
uploadAssetDirect,
getAssetLibraries,
type AssetLibraryItem,
} from "@/api/assets"
import { tagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
import { getAudioDuration } from "../../../utils/audio"
interface UseVoiceUploadOptions {
voiceLibrary?: { id: string; kind: string }
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
}
/**
* 配音素材上传 Hook
* 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签
*/
export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) {
const queryClient = useQueryClient()
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
setUploadProgress(0)
try {
// 1. 获取或等待 voice library
let lib = voiceLibrary
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync()
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
// 2. 上传文件(带进度)
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
// 3. 获取音频时长
const duration = await getAudioDuration(data.file)
// 4. 创建素材记录
const asset = await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
duration,
}),
})
// 5. 打标签(标签走独立 API)
if (data.tagIds.length > 0) {
await tagAsset(asset.id, data.tagIds)
}
} finally {
setUploadProgress(null)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
onError: (err: Error) => {
message.error(err.message || "上传失败,请重试")
},
})
const handleUpload = useCallback(
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!data.file) return
uploadMutation.mutate({
file: data.file,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
})
},
[uploadMutation],
)
return {
uploadProgress,
isUploading: uploadMutation.isPending,
uploadMutation,
handleUpload,
}
}
@@ -1,17 +1,9 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
createAsset,
updateAsset,
deleteAsset,
uploadAssetDirect,
getAssetLibraries,
type AssetLibraryItem,
} from "@/api/assets"
import { tagAsset, untagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../types"
import { getAudioDuration } from "../../utils/audio"
import { useState } from "react"
import { type VoiceMaterial } from "../../types"
import { type AssetLibraryItem } from "@/api/assets"
import { useVoiceUpload } from "./actions/useVoiceUpload"
import { useVoiceEdit } from "./actions/useVoiceEdit"
import { useVoiceDelete } from "./actions/useVoiceDelete"
interface UseVoiceMaterialActionsOptions {
voiceLibrary?: { id: string; kind: string }
@@ -21,184 +13,56 @@ interface UseVoiceMaterialActionsOptions {
/**
* 配音素材操作 Hook
* 封装上传、编辑、删除等变更操作及相关 UI 状态
* 组合上传、编辑、删除三个子 Hook,统一管理弹窗状态
*/
export function useVoiceMaterialActions({
voiceLibrary,
materials,
createLibMutation,
}: UseVoiceMaterialActionsOptions) {
const queryClient = useQueryClient()
// ── 弹窗状态 ──────────────────────────────────────────────
// ── 弹窗状态 ──
const [uploadOpen, setUploadOpen] = useState(false)
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
// ── 上传进度 ──────────────────────────────────────────────
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
// ── 子领域 Hooks ──
const { uploadProgress, uploadMutation } = useVoiceUpload({ voiceLibrary, createLibMutation })
const { editMutation } = useVoiceEdit({ materials })
const { handleDelete } = useVoiceDelete({ materials })
// ── 上传 mutation ─────────────────────────────────────────
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
setUploadProgress(0)
try {
// 1. 获取或等待 voice library
let lib = voiceLibrary
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync()
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
/* ── 操作 handlers(关联弹窗状态) ── */
// 2. 上传文件(带进度)
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
// 3. 获取音频时长
const duration = await getAudioDuration(data.file)
// 4. 创建素材记录
const asset = await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
duration,
}),
})
// 5. 打标签(标签走独立 API)
if (data.tagIds.length > 0) {
await tagAsset(asset.id, data.tagIds)
}
} finally {
setUploadProgress(null)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
onError: (err: Error) => {
message.error(err.message || "上传失败,请重试")
},
})
// ── 编辑 mutation ─────────────────────────────────────────
const editMutation = useMutation({
mutationFn: async (data: {
id: string
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
// 1. 更新基础信息
await updateAsset(data.id, {
name: data.name,
metadata: buildMetadata({
gender: data.gender,
description: data.description,
}),
})
// 2. 对比标签差异,调用 tag/untag API
const currentAsset = materials.find((m) => m.id === data.id)
const oldTagIds = currentAsset?.tagIds ?? []
const newTagIds = data.tagIds
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
if (toAdd.length > 0) {
await tagAsset(data.id, toAdd)
}
for (const tagId of toRemove) {
await untagAsset(data.id, tagId)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
// ── 删除 mutation ─────────────────────────────────────────
const deleteMutation = useMutation({
mutationFn: (assetId: string) => deleteAsset(assetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
},
})
/* ── 数据操作 handlers ──────────────────────────────────── */
const handleUpload = useCallback(
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!data.file) return
uploadMutation.mutate(
{
file: data.file,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
},
{
onSuccess: () => {
setUploadOpen(false)
},
},
)
},
[uploadMutation],
)
const handleEdit = useCallback(
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!editingMaterial) return
editMutation.mutate({
id: editingMaterial.id,
const handleUpload = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!data.file) return
uploadMutation.mutate(
{
file: data.file,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
})
setEditingMaterial(null)
},
[editingMaterial, editMutation],
)
},
{
onSuccess: () => {
setUploadOpen(false)
},
},
)
}
const handleDelete = useCallback(
(id: string, onBeforeDelete?: () => void) => {
const material = materials.find((m) => m.id === id)
if (!material) return
if (onBeforeDelete) onBeforeDelete()
deleteMutation.mutate(id)
},
[materials, deleteMutation],
)
const handleEdit = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!editingMaterial) return
editMutation.mutate({
id: editingMaterial.id,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
})
setEditingMaterial(null)
}
return {
// 上传 & 编辑状态
// 上传 & 编辑 loading 状态
uploadProgress,
isUploading: uploadMutation.isPending,
isEditing: editMutation.isPending,
@@ -1,16 +1,9 @@
import React from "react"
import {
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
ReloadOutlined,
CloseCircleOutlined,
UserOutlined,
} from "@ant-design/icons"
import { DeleteOutlined, ReloadOutlined, CloseCircleOutlined } from "@ant-design/icons"
import { Tooltip } from "antd"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
import CardHeader from "./clone-voice-card/CardHeader"
import CardFooter from "./clone-voice-card/CardFooter"
export interface CloneVoiceCardProps {
voice: ClonedVoiceDisplay
@@ -28,26 +21,18 @@ export interface CloneVoiceCardProps {
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
voice,
isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onDelete,
onRetry,
onShowDetail,
...footerProps
}) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
const genderText =
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender
return (
<div
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
onClick={isFailed ? undefined : onShowDetail}
>
{/* 右上角操作按钮 */}
<div className="xx-clone-card-actions">
<Tooltip title="删除">
<button
@@ -77,38 +62,8 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
)}
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-clone-card-header">
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
<CardHeader voice={voice} />
{/* 描述 */}
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
{/* 元信息 */}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
{/* 错误信息 */}
{isFailed && voice.errorMessage && (
<div className="xx-clone-error">
<CloseCircleOutlined />
@@ -116,63 +71,7 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
</div>
)}
{/* 底部操作区 */}
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse()
}}
>
使
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
)}
</div>
<CardFooter voice={voice} isPlaying={isPlaying} onRetry={onRetry} {...footerProps} />
</div>
)
}
@@ -0,0 +1,87 @@
import React from "react"
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
interface CardFooterProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
currentTime: number
onPlay: () => void
onPause: () => void
onUse: () => void
onRetry: () => void
}
const CardFooter: React.FC<CardFooterProps> = ({
voice,
isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onRetry,
}) => {
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
return (
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse()
}}
>
使
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
)}
</div>
)
}
export default CardFooter
@@ -0,0 +1,51 @@
import React from "react"
import { SoundOutlined, UserOutlined } from "@ant-design/icons"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
interface CardHeaderProps {
voice: ClonedVoiceDisplay
}
const genderTextOf = (gender: string) =>
gender === "male" ? "男声" : gender === "female" ? "女声" : gender
const CardHeader: React.FC<CardHeaderProps> = ({ voice }) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const isProcessing = voice.status === "processing"
const genderText = genderTextOf(voice.gender ?? "")
return (
<>
<div className="xx-clone-card-header">
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
</>
)
}
export default CardHeader
+1
View File
@@ -1,3 +1,4 @@
// 重构:DuplicationUpload 页面已拆分为子组件(UploadZone/InfoSidebar/UploadProgress等)
import { describe, expect, it, vi, beforeEach } from "vitest"
import {
uploadForDuplication,
+1
View File
@@ -1,4 +1,5 @@
import React from "react"
// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import CloneModal from "@/components/voice/CloneModal"
+5
View File
@@ -122,6 +122,11 @@ import "@/pages/templates/hooks/useTemplateLibrary"
import "@/pages/templates/hooks/useTemplateDetail"
import "@/pages/templates/components/template-library/TemplateCard"
import "@/pages/templates/components/template-library/TemplateDetailModal"
import "@/pages/templates/components/template-library/template-detail-modal/PreviewArea"
import "@/pages/templates/components/template-library/template-detail-modal/SegmentList"
import "@/pages/templates/components/template-library/template-detail-modal/StyleConfig"
import "@/pages/templates/components/template-library/template-detail-modal/DetailFooter"
import "@/pages/templates/components/template-library/template-detail-modal/types"
import "@/pages/templates/components/template-library/TemplateHeader"
import "@/pages/templates/components/template-library/TemplateToolbar"
import "@/pages/templates/components/template-library/TemplateGrid"
@@ -2,6 +2,9 @@
* GeneratePage 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* generate 目录下所有文件的改动(包括 Phase 3 子组件)
*
* 重构记录:
* - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook
*/
import { describe, it, expect } from "vitest"
+10
View File
@@ -12,6 +12,13 @@ import "@/pages/voice-materials/VoiceMaterialLibrary"
import "@/pages/voice-materials/components/TagSelector"
import "@/pages/voice-materials/components/MaterialForm"
import "@/pages/voice-materials/components/VoiceMaterialCard"
import "@/pages/voice-materials/components/voice-material-card/CardHeader"
import "@/pages/voice-materials/components/voice-material-card/CardTags"
import "@/pages/voice-materials/components/voice-material-card/CardMeta"
import "@/pages/voice-materials/components/voice-material-card/CardPlayer"
import "@/pages/voice-materials/components/voice-material-card/CardActions"
import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox"
import "@/pages/voice-materials/components/voice-material-card/types"
import "@/pages/voice-materials/components/VoiceMaterialRow"
import "@/pages/voice-materials/components/Toolbar"
import "@/pages/voice-materials/components/TagFilterBar"
@@ -33,6 +40,9 @@ describe("VoiceMaterialLibrary module smoke test", () => {
// Hooks
import "@/pages/voice-materials/hooks/useVoiceMaterials"
import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions"
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload"
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceEdit"
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceDelete"
import "@/pages/voice-materials/hooks/useTtsSynthesize"
import "@/pages/voice-materials/hooks/useAudioPlayer"
import "@/pages/voice-materials/hooks/useBatchOperations"
@@ -6,8 +6,6 @@
from __future__ import annotations
from typing import Any, Optional
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
@@ -261,7 +259,6 @@ def db_to_linear(db: float) -> float:
Returns:
线性音量值
"""
import math
return 10 ** (db / 20.0)
+485
View File
@@ -0,0 +1,485 @@
"""画中画(PiP)引擎纯逻辑模块.
从 pip_engine.py 抽离的纯函数,0 FFmpeg 依赖,可完全单测。
原模块 pip_engine.py 保持不变,向后兼容。
抽离范围:
- 滤镜链构建(scale / 圆角 / 边框 / 透明度 / 动画 / overlay
- 位置与尺寸计算辅助(封装 domain 层调用)
- 完整 PiP 滤镜链编排
- 配置验证与降级策略判断
"""
from __future__ import annotations
import logging
from pathlib import Path
from packages.domain.pip_config import (
ANIMATION_FADE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
PiPLayerConfig,
calculate_pip_position,
parse_size_value,
)
logger = logging.getLogger(__name__)
# ── 尺寸与位置 ────────────────────────────────────────────────────────────────
def compute_pip_size(
layer: PiPLayerConfig,
output_width: int,
output_height: int,
) -> tuple[int, int]:
"""计算画中画图层的实际像素尺寸.
Args:
layer: 图层配置
output_width: 输出视频宽度
output_height: 输出视频高度
Returns:
(width, height) 像素值
"""
pip_w = parse_size_value(layer.width, output_width)
if layer.height:
pip_h = parse_size_value(layer.height, output_height)
else:
# 按宽度等比例(默认 16:9
pip_h = int(pip_w * 9 / 16)
# 钳制到输出尺寸内
pip_w = max(1, min(pip_w, output_width))
pip_h = max(1, min(pip_h, output_height))
return pip_w, pip_h
def compute_pip_position(
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
output_width: int,
output_height: int,
) -> tuple[int, int]:
"""计算画中画的实际位置 (x, y).
封装 domain 层的 calculate_pip_position
提供默认值并做边界钳制。
"""
x, y = calculate_pip_position(
position=layer.position,
output_width=output_width,
output_height=output_height,
pip_width=pip_width,
pip_height=pip_height,
margin=layer.margin,
custom_x=layer.x,
custom_y=layer.y,
)
# 边界钳制:确保不超出画面
x = max(0, min(x, output_width - pip_width))
y = max(0, min(y, output_height - pip_height))
return x, y
# ── 预处理滤镜 ────────────────────────────────────────────────────────────────
def build_pip_pre_filter(
input_label: str,
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
output_label: str,
) -> str:
"""构建单个 PiP 图层的预处理滤镜链.
处理顺序:scale → 圆角裁剪(可选)→ 边框(可选)→ 透明度 → 动画(可选)
Args:
input_label: 输入标签(带方括号,如 "[1:v]"
layer: 图层配置
pip_width: 缩放后的宽度(像素)
pip_height: 缩放后的高度(像素)
output_label: 输出标签(不带方括号)
Returns:
filter_complex 片段,如 "[1:v]scale=...,setsar=1[pip_pre_0]"
"""
filters: list[str] = []
# Step 1: scale + SAR
filters.append(f"scale={pip_width}:{pip_height}")
filters.append("setsar=1")
# Step 2: 圆角裁剪
if layer.corner_radius > 0:
r = min(layer.corner_radius, pip_width // 2, pip_height // 2)
# 用 geq + 圆形遮罩实现四角圆角
filters.append(
"format=yuva420p,"
"geq="
"lum='lum(X,Y)':"
"cb='cb(X,Y)':"
"cr='cr(X,Y)':"
f"a='if(lt(X,{r})*lt(Y,{r}),"
f"gt(hypot({r}-X,{r}-Y),{r})*0+1,"
f"if(gt(X,W-{r})*lt(Y,{r}),"
f"gt(hypot(X-(W-{r}),{r}-Y),{r})*0+1,"
f"if(lt(X,{r})*gt(Y,H-{r}),"
f"gt(hypot({r}-X,Y-(H-{r})),{r})*0+1,"
f"if(gt(X,W-{r})*gt(Y,H-{r}),"
f"gt(hypot(X-(W-{r}),Y-(H-{r})),{r})*0+1,1))))'"
)
# Step 3: 边框
if layer.border_width > 0:
bw = layer.border_width
color = layer.border_color
filters.append(f"pad={pip_width + 2 * bw}:{pip_height + 2 * bw}:{bw}:{bw}:{color}")
# Step 4: 透明度
if layer.opacity < 1.0:
alpha = max(0.0, min(1.0, layer.opacity))
filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}")
# Step 5: 入场出场动画(fade 类直接在预处理中加)
anim_filters = build_animation_filters(layer, pip_width, pip_height)
if anim_filters:
filters.extend(anim_filters)
return f"{input_label}{','.join(filters)}[{output_label}]"
def build_animation_filters(
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
) -> list[str]:
"""构建 fade 类入场出场动画滤镜.
注意:slide 类动画由 overlay 表达式处理,不在此函数内。
Returns:
滤镜字符串列表(每项是一个完整 filter,可直接用逗号连接)
"""
filters: list[str] = []
anim_dur = max(0.0, layer.animation_duration)
# 入场动画
if layer.animation_in == ANIMATION_FADE and anim_dur > 0:
filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1")
# 出场动画(需要总时长)
if layer.animation_out == ANIMATION_FADE and anim_dur > 0 and layer.duration is not None and layer.duration > 0:
start_fade = max(0.0, layer.duration - anim_dur)
filters.append(f"fade=t=out:st={start_fade}:d={anim_dur}:alpha=1")
return filters
# ── Overlay 表达式 ────────────────────────────────────────────────────────────
def build_overlay_expr(
layer: PiPLayerConfig,
base_x: int,
base_y: int,
pip_width: int,
pip_height: int,
output_width: int,
output_height: int,
) -> tuple[str, str]:
"""构建 overlay 滤镜的 x/y 表达式(支持滑动动画).
Args:
layer: 图层配置
base_x: 基础 x 坐标(无动画时的最终位置)
base_y: 基础 y 坐标
pip_width: PiP 图层宽度
pip_height: PiP 图层高度
output_width: 输出视频宽度
output_height: 输出视频高度
Returns:
(x_expr, y_expr) — 可直接传入 overlay= 的参数字符串
无动画时返回纯数字字符串,有动画时返回带引号的表达式
"""
anim_dur = max(0.0, layer.animation_duration)
x_expr = str(base_x)
y_expr = str(base_y)
# ── 入场滑入动画 ──
if anim_dur > 0:
if layer.animation_in == ANIMATION_SLIDE_LEFT:
# 从左侧滑入:x 从 -pip_width 变化到 base_x
x_expr = (
f"'{base_x}+if(lt(t,{anim_dur})," f"{-pip_width}+t/{anim_dur}*({base_x + pip_width})," f"{base_x})'"
)
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
# 从右侧滑入:x 从 output_width 变化到 base_x
x_expr = (
f"'{base_x}+if(lt(t,{anim_dur}),"
f"{output_width}-t/{anim_dur}*({output_width - base_x}),"
f"{base_x})'"
)
elif layer.animation_in == ANIMATION_SLIDE_TOP:
# 从顶部滑入
y_expr = (
f"'{base_y}+if(lt(t,{anim_dur})," f"{-pip_height}+t/{anim_dur}*({base_y + pip_height})," f"{base_y})'"
)
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
# 从底部滑入
y_expr = (
f"'{base_y}+if(lt(t,{anim_dur}),"
f"{output_height}-t/{anim_dur}*({output_height - base_y}),"
f"{base_y})'"
)
# ── 出场滑出动画(需要总时长) ──
if layer.duration is not None and layer.duration > 0 and anim_dur > 0:
out_start = layer.duration - anim_dur
if out_start < 0:
out_start = 0
if layer.animation_out == ANIMATION_SLIDE_LEFT:
# 向左滑出
x_expr = (
f"'{base_x}+if(gt(t,{out_start}),"
f"{base_x}-(t-{out_start})/{anim_dur}*({base_x + pip_width}),"
f"{base_x})'"
)
elif layer.animation_out == ANIMATION_SLIDE_RIGHT:
# 向右滑出
x_expr = (
f"'{base_x}+if(gt(t,{out_start}),"
f"{base_x}+(t-{out_start})/{anim_dur}*({output_width - base_x + pip_width}),"
f"{base_x})'"
)
elif layer.animation_out == ANIMATION_SLIDE_TOP:
# 向上滑出
y_expr = (
f"'{base_y}+if(gt(t,{out_start}),"
f"{base_y}-(t-{out_start})/{anim_dur}*({base_y + pip_height}),"
f"{base_y})'"
)
elif layer.animation_out == ANIMATION_SLIDE_BOTTOM:
# 向下滑出
y_expr = (
f"'{base_y}+if(gt(t,{out_start}),"
f"{base_y}+(t-{out_start})/{anim_dur}*({output_height - base_y + pip_height}),"
f"{base_y})'"
)
return x_expr, y_expr
def build_enable_expr(
layer: PiPLayerConfig,
) -> str:
"""构建 overlay 的 enable 时间控制表达式.
Returns:
enable 表达式片段,如 ":enable='between(t,1,5)'"
无时间限制时返回空字符串
"""
start = max(0.0, layer.start_time)
duration = layer.duration
if start <= 0 and (duration is None or duration <= 0):
return ""
if duration and duration > 0:
end = start + duration
return f":enable='between(t,{start},{end})'"
else:
return f":enable='gte(t,{start})'"
# ── 完整滤镜链 ────────────────────────────────────────────────────────────────
def build_pip_filters(
base_label: str,
layers: list[PiPLayerConfig],
source_paths: list[Path | str],
*,
output_width: int,
output_height: int,
base_input_idx: int = 0,
) -> tuple[list[str], list[str], str]:
"""构建完整的画中画滤镜链和输入参数(纯函数版).
与 PiPEngine.build_pip_filters 对应,但不依赖类实例,
所有参数显式传入,方便测试。
Args:
base_label: 底层视频标签(不带方括号)
layers: 图层配置列表
source_paths: 对应每个图层的源文件路径列表
output_width: 输出视频宽度
output_height: 输出视频高度
base_input_idx: PiP 素材的起始输入索引
Returns:
(filter_parts, input_args, final_label)
- filter_parts: 滤镜片段列表(用 ; 连接成 filter_complex
- input_args: 输入参数列表 ["-i", path, "-i", path, ...]
- final_label: 最终输出标签(不带方括号)
Raises:
ValueError: layers 和 source_paths 长度不一致
"""
if len(layers) != len(source_paths):
raise ValueError(f"layers ({len(layers)}) 和 source_paths ({len(source_paths)}) 长度不一致")
if not layers:
return [], [], base_label
filter_parts: list[str] = []
input_args: list[str] = []
current_label = base_label
for i, (layer, path) in enumerate(zip(layers, source_paths, strict=False)):
# 计算实际大小
pip_w, pip_h = compute_pip_size(layer, output_width, output_height)
# 添加输入
input_args.extend(["-i", str(path)])
# 实际输入索引
actual_input_idx = base_input_idx + i
# 预处理标签
pre_label = f"pip_pre_{i}"
# 构建预处理滤镜
pre_filter = build_pip_pre_filter(
input_label=f"[{actual_input_idx}:v]",
layer=layer,
pip_width=pip_w,
pip_height=pip_h,
output_label=pre_label,
)
filter_parts.append(pre_filter)
# 计算位置
base_x, base_y = compute_pip_position(layer, pip_w, pip_h, output_width, output_height)
# 构建 overlay 表达式
x_expr, y_expr = build_overlay_expr(layer, base_x, base_y, pip_w, pip_h, output_width, output_height)
# 时间控制
enable_expr = build_enable_expr(layer)
# 合成标签
combined_label = f"pip_combined_{i}"
# overlay 滤镜
overlay_filter = (
f"[{current_label}][{pre_label}]" f"overlay={x_expr}:{y_expr}{enable_expr}" f"[{combined_label}]"
)
filter_parts.append(overlay_filter)
current_label = combined_label
return filter_parts, input_args, current_label
# ── 配置验证 ──────────────────────────────────────────────────────────────────
def validate_pip_layer(layer: PiPLayerConfig) -> tuple[bool, str]:
"""验证单个 PiP 图层配置是否合法.
Returns:
(is_valid, error_message) — 合法时 error_message 为空
"""
errors: list[str] = []
# 源类型检查
if not layer.source_type:
errors.append("source_type 不能为空")
elif layer.source_type not in ("local_path", "asset_id", "url"):
errors.append(f"不支持的 source_type: {layer.source_type}")
if not layer.source:
errors.append("source 不能为空")
# 尺寸检查
if layer.width is None or layer.width == "":
errors.append("width 不能为空")
# 位置检查
valid_positions = {
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
"custom",
}
if layer.position not in valid_positions:
errors.append(f"不支持的 position: {layer.position}")
# 数值范围检查
if layer.opacity < 0.0 or layer.opacity > 1.0:
errors.append(f"opacity 必须在 0-1 之间: {layer.opacity}")
if layer.corner_radius < 0:
errors.append(f"corner_radius 不能为负: {layer.corner_radius}")
if layer.border_width < 0:
errors.append(f"border_width 不能为负: {layer.border_width}")
if layer.animation_duration < 0:
errors.append(f"animation_duration 不能为负: {layer.animation_duration}")
if layer.start_time < 0:
errors.append(f"start_time 不能为负: {layer.start_time}")
if layer.duration is not None and layer.duration < 0:
errors.append(f"duration 不能为负: {layer.duration}")
# 动画类型检查
valid_anims = {
"",
None,
ANIMATION_FADE,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
ANIMATION_SLIDE_BOTTOM,
}
if layer.animation_in and layer.animation_in not in valid_anims:
errors.append(f"不支持的 animation_in: {layer.animation_in}")
if layer.animation_out and layer.animation_out not in valid_anims:
errors.append(f"不支持的 animation_out: {layer.animation_out}")
return (len(errors) == 0, "; ".join(errors))
def count_visible_layers(layers: list[PiPLayerConfig]) -> int:
"""统计可见图层数量(排除完全透明的)."""
count = 0
for layer in layers:
if layer.opacity > 0:
count += 1
return count
def sort_layers_by_z_index(layers: list[PiPLayerConfig]) -> list[PiPLayerConfig]:
"""按 z_index 从小到大排序图层(z_index 小的先画,在底层)."""
return sorted(layers, key=lambda layer: layer.z_index)
@@ -13,12 +13,8 @@
from __future__ import annotations
from typing import Optional
from packages.domain.speed_config import (
DEFAULT_SPEED,
MAX_SPEED,
MIN_SPEED,
SpeedConfig,
_split_atempo_stages,
)
+3 -2
View File
@@ -8,9 +8,10 @@ from packages.adapters.sqlalchemy_impl import (
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
settings = get_settings()
ensure_database_exists(settings.database_url)
_db_url = settings.effective_database_url
ensure_database_exists(_db_url)
engine, SessionLocal = build_session_factory(
settings.database_url,
_db_url,
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_timeout=settings.database_pool_timeout,
@@ -48,12 +48,20 @@ def build_session_factory(
return engine, session_factory
def _is_sqlite(database_url: str) -> bool:
"""检测是否为 SQLite 数据库 URL."""
return database_url.startswith("sqlite")
def _build_admin_url(database_url: str) -> URL:
url = make_url(database_url)
return url.set(database="postgres")
def ensure_database_exists(database_url: str) -> None:
"""确保数据库存在(仅 PostgreSQL 需要,SQLite 自动创建)."""
if _is_sqlite(database_url):
return
target_url = make_url(database_url)
admin_engine = create_engine(_build_admin_url(database_url), isolation_level="AUTOCOMMIT")
try:
@@ -70,6 +78,14 @@ def ensure_database_exists(database_url: str) -> None:
def initialize_database(engine) -> None:
"""初始化数据库 schema。
PostgreSQL 使用 advisory lock 防止并发初始化冲突;
SQLite 直接 create_all(单文件,无并发风险)。
"""
if _is_sqlite(str(engine.url)):
Base.metadata.create_all(bind=engine)
return
with engine.connect() as connection:
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": SCHEMA_INIT_LOCK_ID})
try:
+13
View File
@@ -34,6 +34,9 @@ class SharedSettings(BaseSettings):
database_pool_timeout: int = 30
database_pool_recycle: int = 3600
# 测试用:使用 SQLite 内存数据库(CI 环境无需 PostgreSQL
use_in_memory_db: bool = False
# ── Redis ────────────────────────────────────────────────────────────
redis_url: str = "redis://localhost:6379/0"
@@ -66,6 +69,16 @@ class SharedSettings(BaseSettings):
doubao_timeout: int = 30
doubao_max_retries: int = 2
@property
def effective_database_url(self) -> str:
"""返回实际使用的数据库 URL。
当 USE_IN_MEMORY_DB=True 时返回 SQLite 内存 URL,否则返回 database_url。
"""
if self.use_in_memory_db:
return "sqlite:///./test.db"
return self.database_url
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
-11
View File
@@ -22,22 +22,11 @@ import urllib.error
import urllib.request
from urllib.parse import urljoin
from packages.domain.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
ALLOWED_IMAGE_MIME_TYPES,
)
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
+2
View File
@@ -1,6 +1,8 @@
[pytest]
pythonpath = . apps/api apps/worker packages
testpaths = tests
# importlib 模式避免同名测试文件的模块名冲突
addopts = --import-mode=importlib
# ===== 覆盖率配置 =====
# 覆盖率统计范围(供 --cov 使用时的默认源)
+715
View File
@@ -0,0 +1,715 @@
"""video_filter_builder 单测.
domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
"""
from __future__ import annotations
import dataclasses
from unittest.mock import MagicMock
import pytest
from packages.domain.video_filter_builder import (
DEFAULT_CLIP_DURATION,
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
XFADE_TRANSITION_MAP,
ClipFilterChain,
build_clip_filter,
build_concat_filter,
build_filter_complex,
build_xfade_filter,
chain_filters,
has_audio,
)
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _make_chain(
clip_id: str = "c1",
input_index: int = 0,
duration: float = 3.0,
has_audio: bool = True,
filters: list[str] | None = None,
) -> ClipFilterChain:
"""快速创建 ClipFilterChain."""
if filters is None:
filters = ["scale=1280:720", "fps=25", "trim=0:3"]
return ClipFilterChain(
clip_id=clip_id,
input_index=input_index,
video_label=f"v{input_index}",
audio_label=f"a{input_index}" if has_audio else None,
filters=filters,
duration=duration,
)
def _mock_clip(
clip_id: str = "c1",
duration: float = 5.0,
start_time: float = 0.0,
clip_type: str = "video",
) -> MagicMock:
"""创建 mock 的 EditPlanClip."""
clip = MagicMock()
clip.id = clip_id
clip.duration = duration
clip.start_time = start_time
clip.clip_type = clip_type
return clip
# ═══════════════════════════════════════════════════════════════════════════════
# 常量测试
# ═══════════════════════════════════════════════════════════════════════════════
class TestConstants:
"""常量默认值测试."""
def test_default_resolution(self):
"""默认分辨率为 1280x720."""
assert DEFAULT_OUTPUT_WIDTH == 1280
assert DEFAULT_OUTPUT_HEIGHT == 720
def test_default_fps(self):
"""默认帧率 25."""
assert DEFAULT_FPS == 25
def test_default_transition_duration(self):
"""默认转场时长 0.5s."""
assert DEFAULT_TRANSITION_DURATION == 0.5
def test_default_clip_duration(self):
"""默认片段时长 5s."""
assert DEFAULT_CLIP_DURATION == 5.0
def test_xfade_map_contains_common_transitions(self):
"""xfade 转场映射包含常见类型."""
assert "fade" in XFADE_TRANSITION_MAP.values()
assert "slideleft" in XFADE_TRANSITION_MAP.values()
assert "slideright" in XFADE_TRANSITION_MAP.values()
assert "dissolve" in XFADE_TRANSITION_MAP.values()
assert "wipeleft" in XFADE_TRANSITION_MAP.values()
assert len(XFADE_TRANSITION_MAP) >= 5
# ═══════════════════════════════════════════════════════════════════════════════
# ClipFilterChain 数据类
# ═══════════════════════════════════════════════════════════════════════════════
class TestClipFilterChain:
"""数据类结构测试."""
def test_creation(self):
"""创建 ClipFilterChain."""
chain = ClipFilterChain(
clip_id="c1",
input_index=0,
video_label="v0",
audio_label="a0",
filters=["scale=1280:720"],
duration=5.0,
)
assert chain.clip_id == "c1"
assert chain.input_index == 0
assert chain.video_label == "v0"
assert chain.audio_label == "a0"
assert chain.filters == ["scale=1280:720"]
assert chain.duration == 5.0
def test_no_audio(self):
"""无音频流."""
chain = _make_chain(has_audio=False)
assert chain.audio_label is None
def test_frozen(self):
"""frozen dataclass 不可修改."""
chain = _make_chain()
with pytest.raises(dataclasses.FrozenInstanceError):
chain.duration = 10.0 # type: ignore
# ═══════════════════════════════════════════════════════════════════════════════
# chain_filters
# ═══════════════════════════════════════════════════════════════════════════════
class TestChainFilters:
"""滤镜串联测试."""
def test_single_filter(self):
"""单个滤镜."""
result = chain_filters(["scale=1280:720"], "v0")
assert result == "[0:v]scale=1280:720[v0]"
def test_multiple_filters(self):
"""多个滤镜用逗号连接."""
result = chain_filters(["scale=1280:720", "fps=25", "trim=0:5"], "v0")
assert "scale=1280:720,fps=25,trim=0:5" in result
assert result.startswith("[0:v]")
assert result.endswith("[v0]")
def test_empty_filters(self):
"""空滤镜列表."""
result = chain_filters([], "out")
assert result == "[0:v][out]"
def test_custom_input_label(self):
"""自定义输入标签."""
result = chain_filters(["fps=30"], "v1", input_label="1:v")
assert result.startswith("[1:v]")
assert result.endswith("[v1]")
def test_custom_output_label(self):
"""自定义输出标签."""
result = chain_filters(["scale=640:480"], "my_label")
assert result.endswith("[my_label]")
# ═══════════════════════════════════════════════════════════════════════════════
# has_audio
# ═══════════════════════════════════════════════════════════════════════════════
class TestHasAudio:
"""音频判断测试."""
def test_all_have_audio(self):
"""全部有音频."""
chains = [_make_chain(has_audio=True), _make_chain(has_audio=True)]
assert has_audio(chains) is True
def test_none_have_audio(self):
"""全部无音频."""
chains = [_make_chain(has_audio=False), _make_chain(has_audio=False)]
assert has_audio(chains) is False
def test_partial_audio(self):
"""部分有音频."""
chains = [_make_chain(has_audio=True), _make_chain(has_audio=False)]
assert has_audio(chains) is True
def test_empty_list(self):
"""空列表."""
assert has_audio([]) is False
def test_single_with_audio(self):
"""单个有音频."""
assert has_audio([_make_chain(has_audio=True)]) is True
def test_single_without_audio(self):
"""单个无音频."""
assert has_audio([_make_chain(has_audio=False)]) is False
# ═══════════════════════════════════════════════════════════════════════════════
# build_clip_filter
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildClipFilter:
"""单片段滤镜链构建测试."""
def test_basic_video_clip(self):
"""基础视频片段."""
clip = _mock_clip(duration=5.0, start_time=0.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.clip_id == "c1"
assert chain.input_index == 0
assert chain.video_label == "v0"
assert chain.audio_label == "a0"
assert chain.duration == 5.0
assert len(chain.filters) >= 5
def test_contains_scale_filter(self):
"""包含 scale 滤镜."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("scale=1280:720" in f for f in chain.filters)
def test_contains_pad_filter(self):
"""包含 pad 滤镜(居中黑边)."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("pad=1280:720" in f for f in chain.filters)
def test_contains_format_filter(self):
"""包含 format 滤镜(yuv420p."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("format=yuv420p" in f for f in chain.filters)
def test_contains_fps_filter(self):
"""包含 fps 滤镜."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 30)
assert any("fps=30" in f for f in chain.filters)
def test_zero_fps_skipped(self):
"""fps=0 时跳过 fps 滤镜."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 0)
assert not any(f.startswith("fps=") for f in chain.filters)
def test_negative_fps_skipped(self):
"""负 fps 跳过."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, -1)
assert not any(f.startswith("fps=") for f in chain.filters)
def test_start_time_offset(self):
"""有 start_time 时 setpts 带偏移."""
clip = _mock_clip(start_time=2.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("PTS-STARTPTS+2.0/TB" in f for f in chain.filters)
def test_zero_start_time_no_offset(self):
"""start_time=0 时无偏移."""
clip = _mock_clip(start_time=0.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("setpts=PTS-STARTPTS" in f for f in chain.filters)
# 不含 +N/TB 偏移
setpts_filters = [f for f in chain.filters if f.startswith("setpts=")]
# 第一个 setpts 是重置的(不含偏移),trim 后还有一个
assert len(setpts_filters) >= 1
def test_contains_trim_filter(self):
"""包含 trim 滤镜."""
clip = _mock_clip(duration=5.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("trim=0:5.0" in f for f in chain.filters)
def test_negative_duration_uses_default(self):
"""duration<=0 时使用默认时长."""
clip = _mock_clip(duration=0.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.duration == DEFAULT_CLIP_DURATION
assert any(f"trim=0:{DEFAULT_CLIP_DURATION}" in f for f in chain.filters)
def test_title_clip_no_audio(self):
"""title 类型片段无音频."""
clip = _mock_clip(clip_type="title")
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.audio_label is None
def test_subtitle_clip_no_audio(self):
"""subtitle 类型片段无音频."""
clip = _mock_clip(clip_type="subtitle")
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.audio_label is None
def test_video_clip_has_audio(self):
"""video 类型片段有音频."""
clip = _mock_clip(clip_type="video")
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.audio_label == "a0"
def test_image_clip_has_audio(self):
"""image 类型默认有音频标签(实际无音流由调用方判断)."""
clip = _mock_clip(clip_type="image")
chain = build_clip_filter(clip, 0, 1280, 720, 25)
# 只有 title/subtitle 被排除
assert chain.audio_label is not None
def test_input_index_matches_label(self):
"""input_index 对应标签编号."""
clip = _mock_clip()
chain = build_clip_filter(clip, 3, 1280, 720, 25)
assert chain.input_index == 3
assert chain.video_label == "v3"
assert chain.audio_label == "a3"
def test_filter_order(self):
"""滤镜顺序:scale → pad → format → fps → setpts → trim."""
clip = _mock_clip(duration=5.0, start_time=1.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
filter_names = [f.split("=")[0] for f in chain.filters]
# scale 在 pad 前
assert filter_names.index("scale") < filter_names.index("pad")
# pad 在 format 前
assert filter_names.index("pad") < filter_names.index("format")
# format 在 fps 前
assert filter_names.index("format") < filter_names.index("fps")
# ═══════════════════════════════════════════════════════════════════════════════
# build_concat_filter
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildConcatFilter:
"""concat 滤镜构建测试."""
def test_empty_returns_empty(self):
"""空列表返回空."""
result, duration = build_concat_filter([])
assert result == ""
assert duration == 0.0
def test_single_clip(self):
"""单个片段."""
chain = _make_chain(clip_id="c1", input_index=0, duration=3.0)
result, total = build_concat_filter([chain])
assert "[0:v]" in result
assert "concat=n=1:v=1:a=0" in result
assert "[outv]" in result
assert total == 3.0
def test_two_clips(self):
"""两个片段 concat."""
chains = [
_make_chain(clip_id="c1", input_index=0, duration=3.0),
_make_chain(clip_id="c2", input_index=1, duration=2.0),
]
result, total = build_concat_filter(chains)
assert "[0:v]" in result
assert "[1:v]" in result
assert "concat=n=2:v=1:a=0[outv]" in result
assert total == 5.0
def test_three_clips(self):
"""三个片段."""
chains = [
_make_chain(clip_id="c1", input_index=0, duration=2.0),
_make_chain(clip_id="c2", input_index=1, duration=3.0),
_make_chain(clip_id="c3", input_index=2, duration=1.0),
]
result, total = build_concat_filter(chains)
assert "concat=n=3:v=1:a=0[outv]" in result
assert total == 6.0
def test_total_duration_sum(self):
"""总时长 = 各片段时长之和."""
chains = [
_make_chain(duration=1.5),
_make_chain(duration=2.5),
_make_chain(duration=3.0),
]
_, total = build_concat_filter(chains)
assert abs(total - 7.0) < 0.001
def test_audio_concat_with_audio(self):
"""有音频时包含音频 concat."""
chains = [
_make_chain(input_index=0, has_audio=True),
_make_chain(input_index=1, has_audio=True),
]
result, _ = build_concat_filter(chains)
assert "[outa]" in result
assert "concat=n=2:v=0:a=1[outa]" in result
def test_audio_normalization(self):
"""音频经过 aformat 归一化."""
chains = [_make_chain(input_index=0, has_audio=True)]
result, _ = build_concat_filter(chains)
assert "aformat=sample_rates=48000" in result
assert "channel_layouts=stereo" in result
assert "sample_fmts=fltp" in result
def test_no_audio_concat(self):
"""无音频时不生成音频 concat."""
chains = [
_make_chain(input_index=0, has_audio=False),
_make_chain(input_index=1, has_audio=False),
]
result, _ = build_concat_filter(chains)
assert "[outa]" not in result
assert "aformat" not in result
def test_partial_audio_only_includes_audio_chains(self):
"""部分有音频时,只对有音频的片段做 concat."""
chains = [
_make_chain(input_index=0, has_audio=True),
_make_chain(input_index=1, has_audio=False),
_make_chain(input_index=2, has_audio=True),
]
result, _ = build_concat_filter(chains)
# 音频 concat 只有 2 个输入
assert "concat=n=2:v=0:a=1[outa]" in result
def test_video_labels_correct(self):
"""视频标签正确."""
chains = [
_make_chain(clip_id="a", input_index=0, duration=1.0),
_make_chain(clip_id="b", input_index=1, duration=1.0),
]
result, _ = build_concat_filter(chains)
assert "[v0]" in result
assert "[v1]" in result
def test_filter_chain_applied_per_clip(self):
"""每个片段都有独立的滤镜链."""
chains = [
_make_chain(input_index=0, filters=["scale=1280:720", "fps=25"]),
_make_chain(input_index=1, filters=["scale=1280:720", "fps=25"]),
]
result, _ = build_concat_filter(chains)
# 两个片段都有滤镜处理
assert result.count("scale=1280:720") == 2
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilter:
"""xfade 转场滤镜构建测试."""
def test_empty_returns_empty(self):
"""空列表返回空."""
result, duration = build_xfade_filter([], 0.5, [])
assert result == ""
assert duration == 0.0
def test_single_clip_copy(self):
"""单个片段用 copy 直接输出."""
chain = _make_chain(clip_id="c1", input_index=0, duration=3.0)
result, total = build_xfade_filter([chain], 0.5, [])
assert "copy[outv]" in result
assert total == 3.0
def test_two_clips_fade_transition(self):
"""两个片段 + fade 转场."""
chains = [
_make_chain(input_index=0, duration=3.0),
_make_chain(input_index=1, duration=2.0),
]
result, total = build_xfade_filter(chains, 0.5, ["cut", "fade"])
assert "xfade=transition=fade" in result
assert "duration=0.5" in result
assert "[outv]" in result
# 总时长 = 3 + 2 - 0.5 = 4.5
assert abs(total - 4.5) < 0.001
def test_three_clips_with_transitions(self):
"""三个片段 + 多个转场."""
chains = [
_make_chain(input_index=0, duration=3.0),
_make_chain(input_index=1, duration=2.0),
_make_chain(input_index=2, duration=4.0),
]
result, total = build_xfade_filter(chains, 0.5, ["cut", "fade", "slideleft"])
# 两个 xfade 转场
assert result.count("xfade=") == 2
assert "xf1" in result # 中间标签
# 总时长 = 3+2+4 - 0.5*2 = 8.0
assert abs(total - 8.0) < 0.001
def test_offset_calculation(self):
"""转场 offset 计算正确."""
chains = [
_make_chain(input_index=0, duration=5.0),
_make_chain(input_index=1, duration=3.0),
]
result, _ = build_xfade_filter(chains, 1.0, ["cut", "fade"])
# offset = 5.0 - 1.0*1 = 4.0
assert "offset=4.000" in result
def test_offset_never_negative(self):
"""offset 不为负."""
chains = [
_make_chain(input_index=0, duration=0.3),
_make_chain(input_index=1, duration=0.3),
]
result, _ = build_xfade_filter(chains, 1.0, ["cut", "fade"])
# offset = max(0, 0.3 - 1.0) = 0
assert "offset=0.000" in result
def test_transition_slide_left(self):
"""slideleft 转场."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "slide_left"])
assert "xfade=transition=slideleft" in result
def test_transition_slide_right(self):
"""slideright 转场."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "slide_right"])
assert "xfade=transition=slideright" in result
def test_transition_dissolve(self):
"""dissolve 转场."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "dissolve"])
assert "xfade=transition=dissolve" in result
def test_unknown_transition_defaults_to_fade(self):
"""未知转场默认 fade."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "unknown_transition"])
assert "xfade=transition=fade" in result
def test_total_duration_minus_overlap(self):
"""总时长 = sum - transition_duration * (n-1)."""
chains = [
_make_chain(duration=10.0),
_make_chain(duration=10.0),
_make_chain(duration=10.0),
]
_, total = build_xfade_filter(chains, 1.0, ["cut", "fade", "wipe"])
# 30 - 2 = 28
assert abs(total - 28.0) < 0.001
def test_total_duration_never_negative(self):
"""总时长不为负."""
chains = [
_make_chain(duration=0.1),
_make_chain(duration=0.1),
]
_, total = build_xfade_filter(chains, 10.0, ["cut", "fade"])
assert total >= 0.0
def test_audio_with_xfade_path(self):
"""xfade 路径下音频也做 concat."""
chains = [
_make_chain(input_index=0, has_audio=True, duration=2.0),
_make_chain(input_index=1, has_audio=True, duration=3.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
assert "[outa]" in result
assert "aformat=" in result
def test_single_xfade_no_audio_processing(self):
"""单片段 xfade 路径不处理音频(与原实现一致)."""
chain = _make_chain(input_index=0, has_audio=True, duration=3.0)
result, _ = build_xfade_filter([chain], 0.5, [])
# 单片段 xfade 只有视频 copy,不处理音频
assert "copy[outv]" in result
assert "[outa]" not in result
assert "acopy" not in result
def test_no_audio_xfade(self):
"""无音频时不生成 [outa]."""
chains = [
_make_chain(input_index=0, has_audio=False, duration=2.0),
_make_chain(input_index=1, has_audio=False, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
assert "[outa]" not in result
def test_intermediate_labels(self):
"""多片段时有中间 xf 标签."""
chains = [
_make_chain(input_index=0, duration=1.0),
_make_chain(input_index=1, duration=1.0),
_make_chain(input_index=2, duration=1.0),
_make_chain(input_index=3, duration=1.0),
]
result, _ = build_xfade_filter(chains, 0.3, ["cut", "fade", "wipe", "dissolve"])
assert "[xf1]" in result
assert "[xf2]" in result
assert "[outv]" in result
def test_transitions_shorter_than_clips(self):
"""transitions 列表比片段短时,后续用默认值."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
_make_chain(input_index=2, duration=2.0),
]
# 只给一个转场(索引1有效,索引2越界)
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
# 第2个转场(索引2)未知 → 默认 fade
assert result.count("xfade=transition=fade") == 2
# ═══════════════════════════════════════════════════════════════════════════════
# build_filter_complex
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildFilterComplex:
"""完整 filter_complex 构建(策略选择)测试."""
def test_empty_returns_empty(self):
"""空列表返回空."""
result, duration = build_filter_complex([], 1280, 720, 0.5, [])
assert result == ""
assert duration == 0.0
def test_single_clip_chain_mode(self):
"""单片段走单链模式."""
chain = _make_chain(input_index=0, duration=3.0, has_audio=True)
result, total = build_filter_complex([chain], 1280, 720, 0.5, [])
assert "[0:v]" in result
assert "[0:a]" in result
assert total == 3.0
def test_single_clip_no_audio(self):
"""单片段无音频."""
chain = _make_chain(input_index=0, duration=3.0, has_audio=False)
result, _ = build_filter_complex([chain], 1280, 720, 0.5, [])
assert "[0:a]" not in result
def test_multiple_clips_all_cut_uses_concat(self):
"""多片段 + 全 cut → 走 concat(高效模式)."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=3.0),
]
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "cut"])
# concat 模式
assert "concat=n=2:v=1:a=0" in result
assert "xfade" not in result
assert total == 5.0
def test_multiple_clips_with_transition_uses_xfade(self):
"""多片段 + 有转场 → 走 xfade."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=3.0),
]
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "fade"])
assert "xfade=" in result
assert abs(total - 4.5) < 0.001
def test_transition_effect_enum_value(self):
"""使用 TransitionEffect 枚举值也能正确判断."""
from packages.domain.template_clip_config import TransitionEffect
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
# 传 TransitionEffect.CUT(不是字符串 "cut"
result, _ = build_filter_complex(
chains,
1280,
720,
0.5,
[TransitionEffect.CUT, TransitionEffect.CUT],
)
# 都是 cut → 走 concat
assert "concat=n=2:v=1:a=0" in result
def test_mixed_cut_and_transition(self):
"""混合 cut 和转场 → 走 xfade."""
chains = [
_make_chain(input_index=0, duration=1.0),
_make_chain(input_index=1, duration=1.0),
_make_chain(input_index=2, duration=1.0),
]
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "fade", "cut"])
# 只要有一个非 cut 转场就走 xfade
assert "xfade=" in result
assert abs(total - 2.0) < 0.001
+579
View File
@@ -0,0 +1,579 @@
"""xfade_builder 单测.
domain 层 XFade 转场滤镜构建纯逻辑模块,0 FFmpeg 依赖。
覆盖:转场名称映射、滤镜链串联、xfade 滤镜链构建(含 duration 钳制)。
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from packages.domain.xfade_builder import (
DEFAULT_TRANSITION_DURATION,
SUPPORTED_TRANSITIONS,
XFADE_TRANSITION_MAP,
XFade_TRANSITION_NAMES,
build_xfade_filter_chain,
chain_filters,
resolve_xfade_transition,
)
# ═══════════════════════════════════════════════════════════════════════════════
# 常量测试
# ═══════════════════════════════════════════════════════════════════════════════
class TestConstants:
"""常量测试."""
def test_default_duration(self):
"""默认转场时长 0.5s."""
assert DEFAULT_TRANSITION_DURATION == 0.5
def test_transition_map_not_empty(self):
"""转场映射非空."""
assert len(XFADE_TRANSITION_MAP) > 10
def test_supported_transitions(self):
"""支持的转场数量与映射键一致."""
assert len(SUPPORTED_TRANSITIONS) == len(XFADE_TRANSITION_MAP)
def test_output_names_subset(self):
"""输出名称是映射值的集合."""
assert XFade_TRANSITION_NAMES == set(XFADE_TRANSITION_MAP.values())
def test_fade_in_map(self):
"""fade 是基础转场."""
assert "fade" in XFADE_TRANSITION_MAP
assert XFADE_TRANSITION_MAP["fade"] == "fade"
def test_dissolve_aliases(self):
"""dissolve 有多个别名."""
assert XFADE_TRANSITION_MAP["dissolve"] == "dissolve"
assert XFADE_TRANSITION_MAP["crossfade"] == "dissolve"
assert XFADE_TRANSITION_MAP["crossdissolve"] == "dissolve"
def test_slide_directions(self):
"""4 方向滑动都有映射."""
assert XFADE_TRANSITION_MAP["slideleft"] == "slideleft"
assert XFADE_TRANSITION_MAP["slideright"] == "slideright"
assert XFADE_TRANSITION_MAP["slideup"] == "slideup"
assert XFADE_TRANSITION_MAP["slidedown"] == "slidedown"
def test_slide_underscore_aliases(self):
"""下划线别名也支持."""
assert XFADE_TRANSITION_MAP["slide_left"] == "slideleft"
assert XFADE_TRANSITION_MAP["slide_right"] == "slideright"
assert XFADE_TRANSITION_MAP["slide_up"] == "slideup"
assert XFADE_TRANSITION_MAP["slide_down"] == "slidedown"
def test_slide_default_direction(self):
"""slide 默认向左滑."""
assert XFADE_TRANSITION_MAP["slide"] == "slideleft"
def test_wipe_directions(self):
"""4 方向擦除."""
assert XFADE_TRANSITION_MAP["wipeleft"] == "wipeleft"
assert XFADE_TRANSITION_MAP["wiperight"] == "wiperight"
assert XFADE_TRANSITION_MAP["wipeup"] == "wipeup"
assert XFADE_TRANSITION_MAP["wipedown"] == "wipedown"
def test_wipe_default(self):
"""wipe 默认向左擦."""
assert XFADE_TRANSITION_MAP["wipe"] == "wipeleft"
def test_zoom(self):
"""缩放转场."""
assert XFADE_TRANSITION_MAP["zoom"] == "zoomin"
assert XFADE_TRANSITION_MAP["zoomin"] == "zoomin"
assert XFADE_TRANSITION_MAP["zoomout"] == "zoomout"
def test_circle_rect(self):
"""圆形/矩形裁剪."""
assert XFADE_TRANSITION_MAP["circle"] == "circlecrop"
assert XFADE_TRANSITION_MAP["circlecrop"] == "circlecrop"
assert XFADE_TRANSITION_MAP["rect"] == "rectcrop"
assert XFADE_TRANSITION_MAP["rectcrop"] == "rectcrop"
# ═══════════════════════════════════════════════════════════════════════════════
# chain_filters
# ═══════════════════════════════════════════════════════════════════════════════
class TestChainFilters:
"""滤镜链串联测试."""
def test_single_filter(self):
"""单个滤镜."""
result = chain_filters(["scale=1280:720"], "v0")
assert result == "[0:v]scale=1280:720[v0]"
def test_multiple_filters(self):
"""多个滤镜用逗号连接."""
result = chain_filters(["scale=1280:720", "fps=25", "format=yuv420p"], "out")
assert "scale=1280:720,fps=25,format=yuv420p" in result
def test_empty_filters(self):
"""空滤镜列表."""
result = chain_filters([], "out")
assert result == "[0:v][out]"
def test_custom_input_label(self):
"""自定义输入标签."""
result = chain_filters(["fps=30"], "v1", input_label="2:v")
assert result.startswith("[2:v]")
def test_custom_output_label(self):
"""自定义输出标签."""
result = chain_filters(["scale=640:480"], "my_output")
assert result.endswith("[my_output]")
def test_preserves_filter_order(self):
"""保持滤镜顺序."""
filters = ["a", "b", "c", "d"]
result = chain_filters(filters, "out")
idx_a = result.index("a")
idx_b = result.index("b")
idx_c = result.index("c")
idx_d = result.index("d")
assert idx_a < idx_b < idx_c < idx_d
# ═══════════════════════════════════════════════════════════════════════════════
# resolve_xfade_transition
# ═══════════════════════════════════════════════════════════════════════════════
class TestResolveXfadeTransition:
"""转场名称解析测试."""
def test_fade(self):
"""fade → fade."""
assert resolve_xfade_transition("fade") == "fade"
def test_dissolve(self):
"""dissolve → dissolve."""
assert resolve_xfade_transition("dissolve") == "dissolve"
def test_slide_left_alias(self):
"""slide_left 别名."""
assert resolve_xfade_transition("slide_left") == "slideleft"
def test_slide_default(self):
"""slide 默认向左."""
assert resolve_xfade_transition("slide") == "slideleft"
def test_zoom_default(self):
"""zoom 默认 zoomin."""
assert resolve_xfade_transition("zoom") == "zoomin"
def test_wipe_default(self):
"""wipe 默认向左擦."""
assert resolve_xfade_transition("wipe") == "wipeleft"
def test_unknown_falls_back_to_fade(self):
"""未知转场回退到 fade."""
assert resolve_xfade_transition("unknown_effect") == "fade"
def test_empty_string_fades(self):
"""空字符串回退到 fade."""
assert resolve_xfade_transition("") == "fade"
def test_enum_value(self):
"""支持枚举(有 .value 属性)."""
mock_enum = MagicMock()
mock_enum.value = "dissolve"
assert resolve_xfade_transition(mock_enum) == "dissolve"
def test_enum_unknown_value_fades(self):
"""枚举值未知时回退到 fade."""
mock_enum = MagicMock()
mock_enum.value = "not_a_real_effect"
assert resolve_xfade_transition(mock_enum) == "fade"
def test_circle_alias(self):
"""circle 别名."""
assert resolve_xfade_transition("circle") == "circlecrop"
def test_rect_alias(self):
"""rect 别名."""
assert resolve_xfade_transition("rect") == "rectcrop"
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — 基础结构
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainBasic:
"""xfade 滤镜链基础结构测试."""
def test_empty_clips(self):
"""空片段返回空."""
result, duration = build_xfade_filter_chain([], [], [])
assert result == ""
assert duration == 0.0
def test_single_clip_copy(self):
"""单个片段用 copy."""
result, duration = build_xfade_filter_chain([5.0], ["v0"], [])
assert "[v0]copy[outv]" in result
assert duration == 5.0
def test_two_clips_fade(self):
"""两个片段 + fade 转场."""
result, total = build_xfade_filter_chain([5.0, 3.0], ["v0", "v1"], ["cut", "fade"])
assert "xfade=transition=fade" in result
assert ":duration=0.500" in result
assert "[outv]" in result
# 总时长 = 5 + 3 - 0.5 = 7.5
assert abs(total - 7.5) < 0.01
def test_three_clips(self):
"""三个片段有 2 个 xfade."""
result, total = build_xfade_filter_chain(
[4.0, 3.0, 5.0],
["v0", "v1", "v2"],
["cut", "fade", "dissolve"],
)
assert result.count("xfade=") == 2
assert "xf1" in result # 中间标签
# 总时长 = 4 + 3 + 5 - 0.5*2 = 11.0
assert abs(total - 11.0) < 0.01
def test_output_label_custom(self):
"""自定义输出标签."""
result, _ = build_xfade_filter_chain(
[5.0, 3.0],
["v0", "v1"],
["cut", "fade"],
output_label="final",
)
assert result.endswith("[final]")
assert "[outv]" not in result
def test_custom_transition_duration(self):
"""自定义转场时长."""
result, total = build_xfade_filter_chain(
[5.0, 3.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
assert ":duration=1.000" in result
assert abs(total - 7.0) < 0.01 # 5+3-1 = 7
def test_intermediate_labels(self):
"""多片段使用中间 xf 标签."""
result, _ = build_xfade_filter_chain(
[1.0, 1.0, 1.0, 1.0, 1.0],
["v0", "v1", "v2", "v3", "v4"],
["cut"] * 5,
)
# 5 个片段 = 4 个 xfade,中间标签 xf1, xf2, xf3
assert "[xf1]" in result
assert "[xf2]" in result
assert "[xf3]" in result
def test_video_labels_used(self):
"""使用传入的视频标签."""
result, _ = build_xfade_filter_chain(
[2.0, 2.0],
["clip_a", "clip_b"],
["cut", "fade"],
)
assert "[clip_a]" in result
assert "[clip_b]" in result
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — offset 计算
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainOffset:
"""xfade offset 计算测试."""
def test_two_clips_offset(self):
"""两片段 offset = dur0 - td."""
result, _ = build_xfade_filter_chain(
[10.0, 5.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
# offset = 10 - 1*1 = 9
assert ":offset=9.000" in result
def test_three_clips_second_offset(self):
"""三片段第二个 offset."""
result, _ = build_xfade_filter_chain(
[5.0, 4.0, 3.0],
["v0", "v1", "v2"],
["cut", "fade", "fade"],
transition_duration=0.5,
)
# 第一个 xfade offset = 5 - 0.5*1 = 4.5
# 第二个:cumulative = 5+4 = 9, offset = 9 - 0.5*2 = 8
assert ":offset=4.500" in result
assert ":offset=8.000" in result
def test_offset_never_negative(self):
"""offset 不为负."""
result, _ = build_xfade_filter_chain(
[0.1, 0.1],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
# 找 offset 的值
import re
offsets = re.findall(r"offset=([\d.]+)", result)
for off in offsets:
assert float(off) >= 0.0
def test_short_first_clip_clamps_td(self):
"""第一个片段很短时,转场时长被钳制."""
result, _ = build_xfade_filter_chain(
[0.3, 2.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
# 第一片段只有 0.3soffset ≈ 0, available ≈ 0.3, td 被钳制
import re
durations = re.findall(r"duration=([\d.]+)", result)
# 第一个 duration 是 xfade 的 duration
xfade_dur = float(durations[0])
assert xfade_dur <= 0.3 # 不能超过第一个片段时长
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — duration 钳制(防 exit 234
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainClamping:
"""duration 钳制逻辑测试(防 FFmpeg exit 234."""
def test_td_not_exceed_first_input(self):
"""转场时长不超过第一个输入的可用时长."""
# 第一个片段 1s,转场 2s → 被钳制
result, total = build_xfade_filter_chain(
[1.0, 3.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=2.0,
)
import re
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
assert float(durations[0]) <= 1.0
# 总时长不会比 1+3 = 4 还大(钳制后 td < 2
assert total < 4.0
def test_td_not_exceed_second_input(self):
"""转场时长不超过第二个片段时长."""
result, _ = build_xfade_filter_chain(
[3.0, 0.2],
["v0", "v1"],
["cut", "fade"],
transition_duration=1.0,
)
import re
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
assert float(durations[0]) <= 0.2
def test_td_minimum_1ms(self):
"""td 至少 1ms."""
result, _ = build_xfade_filter_chain(
[0.0001, 0.0001],
["v0", "v1"],
["cut", "fade"],
transition_duration=0.0,
)
import re
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
if durations:
assert float(durations[0]) >= 0.001
def test_many_short_clips(self):
"""多个极短片段."""
n = 5
durations = [0.2] * n
labels = [f"v{i}" for i in range(n)]
result, total = build_xfade_filter_chain(
durations,
labels,
["cut"] * n,
transition_duration=0.5,
)
# 4 个转场
assert result.count("xfade=") == 4
# 总时长合理:sum = 1.0,减去被钳制的转场
assert total > 0
assert total <= sum(durations)
def test_second_xfade_first_input_is_accumulated(self):
"""第二个 xfade 的第一个输入时长是累积值(考虑之前的转场扣减)."""
# 三个片段,转场比较长,验证第二步钳制
result, total = build_xfade_filter_chain(
[2.0, 2.0, 2.0],
["v0", "v1", "v2"],
["cut", "fade", "fade"],
transition_duration=1.0,
)
# 第一个 xfade: first_input_dur = 2.0, td = min(1.0, 2.0-offset)
# offset = 2 - 1*1 = 1.0, available = 2.0 - 1.0 = 1.0, td = 1.0
# 第二个 xfade: first_input_dur = (2+2) - 1.0 = 3.0(累积 - 已用转场)
# offset = 4 - 1*2 = 2.0, available = 3.0 - 2.0 = 1.0, td = min(1.0, 1.0, 2.0) = 1.0
import re
dur_match = re.findall(r":duration=([\d.]+)", result)
# 两个 xfade,每个 duration 都是 1.0(正常情况)
assert len(dur_match) == 2
assert float(dur_match[0]) == 1.0
assert float(dur_match[1]) == 1.0
def test_total_duration_positive(self):
"""总时长不为负."""
_, total = build_xfade_filter_chain(
[0.1, 0.1],
["v0", "v1"],
["cut", "fade"],
transition_duration=10.0,
)
assert total >= 0.0
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — 转场类型
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainTransitions:
"""不同转场类型测试."""
def test_slideleft_transition(self):
"""slideleft 转场."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "slideleft"])
assert "xfade=transition=slideleft" in result
def test_slide_left_alias_resolved(self):
"""slide_left 别名解析正确."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "slide_left"])
assert "xfade=transition=slideleft" in result
def test_dissolve_transition(self):
"""dissolve 转场."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "dissolve"])
assert "xfade=transition=dissolve" in result
def test_zoom_transition(self):
"""zoom 转场 → zoomin."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "zoom"])
assert "xfade=transition=zoomin" in result
def test_wipe_transition(self):
"""wipe 转场."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "wipeup"])
assert "xfade=transition=wipeup" in result
def test_unknown_transition_fade(self):
"""未知转场回退到 fade."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "nonexistent"])
assert "xfade=transition=fade" in result
def test_cut_resolved_as_fade(self):
"""cut 也回退到 fade(调用方应对 cut 做特殊处理,但这里也能工作)."""
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "cut"])
# cut 不在映射里,回退到 fade
assert "xfade=transition=fade" in result
def test_transitions_shorter_than_clips(self):
"""transitions 比片段少时,超出部分用 cut/fade."""
result, _ = build_xfade_filter_chain(
[1.0, 1.0, 1.0, 1.0],
["v0", "v1", "v2", "v3"],
["cut", "fade"], # 只有 2 个转场
)
# 4 个片段 = 3 个 xfade,第三个用默认(cut→fade
assert result.count("xfade=") == 3
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter_chain — 总时长验证
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilterChainTotalDuration:
"""总时长计算验证."""
def test_two_equal_clips_default_td(self):
"""两个等长片段 + 默认 0.5s 转场."""
_, total = build_xfade_filter_chain(
[5.0, 5.0],
["v0", "v1"],
["cut", "fade"],
)
assert abs(total - 9.5) < 0.01 # 5+5-0.5
def test_three_clips_two_transitions(self):
"""三个片段两个转场."""
_, total = build_xfade_filter_chain(
[3.0, 4.0, 3.0],
["v0", "v1", "v2"],
["cut"] * 3,
transition_duration=0.5,
)
# 10 - 1.0 = 9.0
assert abs(total - 9.0) < 0.01
def test_single_clip_no_transition_loss(self):
"""单个片段无转场扣减."""
_, total = build_xfade_filter_chain([10.0], ["v0"], [])
assert total == 10.0
def test_zero_duration_clips(self):
"""0 时长片段不崩溃."""
result, total = build_xfade_filter_chain(
[0.0, 0.0],
["v0", "v1"],
["cut", "fade"],
)
assert total >= 0.0
assert "xfade=" in result # 仍然生成转场(td 被钳制到最小)
def test_very_long_transition_clamped(self):
"""极长转场被钳制,总时长仍为正."""
_, total = build_xfade_filter_chain(
[2.0, 2.0],
["v0", "v1"],
["cut", "fade"],
transition_duration=100.0,
)
# 总时长 > 0
assert total > 0.0
# 且 < sum(durations) = 4(因为被钳制但还有重叠)
assert total < 4.0
def test_many_clips_linear_total(self):
"""多片段总时长近似线性增长."""
n = 10
durations = [1.0] * n
labels = [f"v{i}" for i in range(n)]
_, total = build_xfade_filter_chain(
durations,
labels,
["cut"] * n,
transition_duration=0.1,
)
# 10 - 9*0.1 = 9.1
assert abs(total - 9.1) < 0.05
+966
View File
@@ -0,0 +1,966 @@
"""PiP Engine 纯逻辑单测.
测试 pip_engine_pure.py 中的所有纯函数,
0 FFmpeg 依赖,快速轻量。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.pip_engine_pure import (
build_animation_filters,
build_enable_expr,
build_overlay_expr,
build_pip_filters,
build_pip_pre_filter,
compute_pip_position,
compute_pip_size,
count_visible_layers,
sort_layers_by_z_index,
validate_pip_layer,
)
from packages.domain.pip_config import (
ANIMATION_FADE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
PiPLayerConfig,
)
# ── 常量与工具 ────────────────────────────────────────────────────────────────
OUTPUT_W = 1080
OUTPUT_H = 1920
def _make_layer(**kwargs) -> PiPLayerConfig:
"""快速创建图层配置."""
defaults = dict(
source_type="local_path",
source="/tmp/test.mp4",
width="25%",
height=None,
position="bottom_right",
margin=20,
opacity=1.0,
corner_radius=0,
border_width=0,
border_color="black",
z_index=0,
start_time=0.0,
duration=None,
animation_in=None,
animation_out=None,
animation_duration=0.5,
)
defaults.update(kwargs)
return PiPLayerConfig(**defaults)
# ═══════════════════════════════════════════════════════════════════════════════
# compute_pip_size
# ═══════════════════════════════════════════════════════════════════════════════
class TestComputePipSize:
"""尺寸计算测试."""
def test_percentage_width_auto_height(self):
"""百分比宽度,自动高度(16:9."""
layer = _make_layer(width="25%")
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == 270 # 1080 * 25%
assert h == 151 # 270 * 9 / 16 = 151.875 → 151
def test_pixel_width_and_height(self):
"""像素宽高."""
layer = _make_layer(width=300, height=200)
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == 300
assert h == 200
def test_pixel_width_percent_height(self):
"""像素宽 + 百分比高."""
layer = _make_layer(width=200, height="10%")
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == 200
assert h == 192 # 1920 * 10%
def test_full_width_clamped(self):
"""超过输出尺寸时钳制到输出范围内."""
layer = _make_layer(width="200%")
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == OUTPUT_W
assert h <= OUTPUT_H # 按比例后高度不超过输出
def test_zero_width_minimum(self):
"""极小尺寸钳制到至少 1 像素."""
layer = _make_layer(width="0%")
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w >= 1
assert h >= 1
def test_pixel_int_width(self):
"""整数像素宽度."""
layer = _make_layer(width=500, height=300)
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == 500
assert h == 300
# ═══════════════════════════════════════════════════════════════════════════════
# compute_pip_position
# ═══════════════════════════════════════════════════════════════════════════════
class TestComputePipPosition:
"""位置计算测试."""
def test_bottom_right(self):
"""右下角位置."""
layer = _make_layer(position="bottom_right", margin=20)
pip_w, pip_h = 200, 150
x, y = compute_pip_position(layer, pip_w, pip_h, OUTPUT_W, OUTPUT_H)
assert x == OUTPUT_W - pip_w - 20
assert y == OUTPUT_H - pip_h - 20
def test_top_left(self):
"""左上角."""
layer = _make_layer(position="top_left", margin=10)
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x == 10
assert y == 10
def test_top_center(self):
"""顶部居中."""
layer = _make_layer(position="top_center", margin=20)
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x == (OUTPUT_W - 200) // 2
assert y == 20
def test_center(self):
"""正中心."""
layer = _make_layer(position="center")
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x == (OUTPUT_W - 200) // 2
assert y == (OUTPUT_H - 150) // 2
def test_custom_position(self):
"""自定义坐标."""
layer = _make_layer(position="custom", x=100, y=200)
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x == 100
assert y == 200
def test_margin_effect(self):
"""不同 margin 值影响位置."""
layer1 = _make_layer(position="bottom_right", margin=0)
layer2 = _make_layer(position="bottom_right", margin=50)
x1, y1 = compute_pip_position(layer1, 200, 150, OUTPUT_W, OUTPUT_H)
x2, y2 = compute_pip_position(layer2, 200, 150, OUTPUT_W, OUTPUT_H)
assert x1 > x2
assert y1 > y2
def test_clamped_when_outside(self):
"""自定义坐标超出画面时钳制到边界内."""
layer = _make_layer(position="custom", x=-50, y=99999)
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x >= 0
assert x <= OUTPUT_W - 200
assert y >= 0
assert y == OUTPUT_H - 150 # y 超出底部,钳制到底部
def test_bottom_center(self):
"""底部居中."""
layer = _make_layer(position="bottom_center", margin=30)
x, y = compute_pip_position(layer, 300, 200, OUTPUT_W, OUTPUT_H)
assert x == (OUTPUT_W - 300) // 2
assert y == OUTPUT_H - 200 - 30
def test_center_left(self):
"""左侧居中."""
layer = _make_layer(position="center_left", margin=15)
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == 15
assert y == (OUTPUT_H - 100) // 2
def test_center_right(self):
"""右侧居中."""
layer = _make_layer(position="center_right", margin=15)
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == OUTPUT_W - 150 - 15
assert y == (OUTPUT_H - 100) // 2
# ═══════════════════════════════════════════════════════════════════════════════
# build_pip_pre_filter
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildPipPreFilter:
"""预处理滤镜构建测试."""
def test_basic_scale_setsar(self):
"""基础:scale + setsar."""
layer = _make_layer()
result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0")
assert result.startswith("[1:v]")
assert "scale=200:150" in result
assert "setsar=1" in result
assert result.endswith("[pip_pre_0]")
def test_corner_radius_filter(self):
"""圆角裁剪滤镜."""
layer = _make_layer(corner_radius=20)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "geq=" in result
assert "format=yuva420p" in result
# 圆角半径应钳制到 min(r, w//2, h//2)
assert "hypot(" in result
def test_corner_radius_clamped(self):
"""圆角半径超过尺寸一半时自动钳制."""
layer = _make_layer(corner_radius=1000) # 超大
result = build_pip_pre_filter("[0:v]", layer, 100, 80, "pre")
# 钳制后 r = min(1000, 50, 40) = 40
# 检查 geq 表达式中的 r 值
import re
r_matches = re.findall(r"lt\(X,(\d+)\)\*lt\(Y,\1\)", result)
assert r_matches
assert int(r_matches[0]) <= 50 # 不超过宽的一半
def test_border_filter(self):
"""边框滤镜."""
layer = _make_layer(border_width=5, border_color="red")
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "pad=210:160:5:5:red" in result
def test_zero_border_no_pad(self):
"""border_width=0 时不加 pad."""
layer = _make_layer(border_width=0)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "pad=" not in result
def test_opacity_filter(self):
"""透明度滤镜."""
layer = _make_layer(opacity=0.5)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "colorchannelmixer=aa=0.5" in result
assert "format=yuva420p" in result
def test_full_opacity_no_alpha(self):
"""opacity=1.0 时不加透明度滤镜."""
layer = _make_layer(opacity=1.0)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "colorchannelmixer" not in result
def test_opacity_clamped_high(self):
"""opacity > 1.0 时钳制."""
layer = _make_layer(opacity=2.0)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
# 钳制到 1.0,不加透明度滤镜
assert "colorchannelmixer=aa=1" not in result
assert "colorchannelmixer" not in result
def test_opacity_clamped_low(self):
"""opacity < 0 时钳制到 0."""
layer = _make_layer(opacity=-0.5)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "colorchannelmixer=aa=0.0" in result
def test_fade_in_animation(self):
"""淡入动画."""
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.3)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "fade=t=in:st=0:d=0.3:alpha=1" in result
def test_fade_out_animation(self):
"""淡出动画(需要 duration."""
layer = _make_layer(
animation_out=ANIMATION_FADE,
animation_duration=0.5,
duration=5.0,
)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "fade=t=out:st=4.5:d=0.5:alpha=1" in result
def test_fade_out_no_duration(self):
"""淡出无 duration 时不加."""
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5, duration=None)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "fade=t=out" not in result
def test_combined_effects(self):
"""多个效果组合:圆角 + 边框 + 透明度."""
layer = _make_layer(
corner_radius=15,
border_width=3,
border_color="white",
opacity=0.8,
)
result = build_pip_pre_filter("[0:v]", layer, 300, 200, "pre")
assert "geq=" in result # 圆角
assert "pad=306:206:3:3:white" in result # 边框
assert "colorchannelmixer=aa=0.8" in result # 透明度
def test_output_label(self):
"""输出标签正确."""
layer = _make_layer()
result = build_pip_pre_filter("[2:v]", layer, 100, 80, "my_label")
assert result.endswith("[my_label]")
def test_input_label(self):
"""输入标签正确."""
layer = _make_layer()
result = build_pip_pre_filter("[5:v]", layer, 100, 80, "out")
assert result.startswith("[5:v]")
# ═══════════════════════════════════════════════════════════════════════════════
# build_animation_filters
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildAnimationFilters:
"""动画滤镜构建测试."""
def test_no_animation(self):
"""无动画返回空列表."""
layer = _make_layer()
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_fade_in_only(self):
"""仅淡入."""
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5)
result = build_animation_filters(layer, 200, 150)
assert len(result) == 1
assert "fade=t=in" in result[0]
def test_fade_out_with_duration(self):
"""淡出(有 duration."""
layer = _make_layer(
animation_out=ANIMATION_FADE,
animation_duration=0.3,
duration=10.0,
)
result = build_animation_filters(layer, 200, 150)
assert len(result) == 1
assert "fade=t=out:st=9.7:d=0.3" in result[0]
def test_fade_out_no_duration_skipped(self):
"""淡出无 duration 时跳过."""
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5)
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_fade_in_and_out(self):
"""淡入 + 淡出."""
layer = _make_layer(
animation_in=ANIMATION_FADE,
animation_out=ANIMATION_FADE,
animation_duration=0.5,
duration=3.0,
)
result = build_animation_filters(layer, 200, 150)
assert len(result) == 2
assert any("fade=t=in" in f for f in result)
assert any("fade=t=out" in f for f in result)
def test_slide_in_not_here(self):
"""slide 动画不在此函数处理."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_zero_duration_no_animation(self):
"""动画时长为 0 时不加."""
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0)
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_negative_duration_clamped(self):
"""负动画时长钳制为 0."""
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=-1)
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_fade_out_start_clamped_to_zero(self):
"""淡出开始时间不为负."""
layer = _make_layer(
animation_out=ANIMATION_FADE,
animation_duration=2.0,
duration=1.0, # 比动画时长短
)
result = build_animation_filters(layer, 200, 150)
assert len(result) == 1
# start = max(0, 1.0 - 2.0) = 0
assert "st=0.0:d=2.0" in result[0]
# ═══════════════════════════════════════════════════════════════════════════════
# build_overlay_expr
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildOverlayExpr:
"""overlay 表达式构建测试."""
def test_no_animation_static_position(self):
"""无动画时返回静态坐标."""
layer = _make_layer()
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert y == "200"
def test_slide_in_from_left(self):
"""从左侧滑入."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert "if(lt(t,0.5)" in x
assert "-150" in x # 起始位置 = -pip_width
assert y == "200" # y 不变
def test_slide_in_from_right(self):
"""从右侧滑入."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_RIGHT, animation_duration=0.5)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert str(OUTPUT_W) in x
assert y == "200"
def test_slide_in_from_top(self):
"""从顶部滑入."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.3)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert "if(lt(t,0.3)" in y
assert "-100" in y
def test_slide_in_from_bottom(self):
"""从底部滑入."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.3)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert str(OUTPUT_H) in y
def test_slide_out_to_left(self):
"""向左滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_LEFT,
animation_duration=0.5,
duration=3.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert "gt(t,2.5)" in x
assert y == "200"
def test_slide_out_to_right(self):
"""向右滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_RIGHT,
animation_duration=0.5,
duration=3.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert "gt(t,2.5)" in x
assert y == "200"
# 向右滑出:结束时 x > base_x(值变大)
# 检查表达式中含增大方向的计算
assert "+(t-2.5)/0.5*" in x
def test_slide_out_to_top(self):
"""向上滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_TOP,
animation_duration=0.5,
duration=5.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert "gt(t,4.5)" in y
def test_slide_out_to_bottom(self):
"""向下滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_BOTTOM,
animation_duration=0.5,
duration=5.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert "gt(t,4.5)" in y
# 向下滑出:y 值增大
assert "+(t-4.5)/0.5*" in y
def test_slide_in_and_out_different_axes(self):
"""滑入(x方向) + 滑出(y方向),两个轴都有动画."""
layer = _make_layer(
animation_in=ANIMATION_SLIDE_LEFT,
animation_out=ANIMATION_SLIDE_BOTTOM,
animation_duration=0.5,
duration=4.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert "lt(t,0.5)" in x # x 方向入场
assert "gt(t,3.5)" in y # y 方向出场
def test_zero_animation_duration_no_effect(self):
"""动画时长为 0 时无效果."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert y == "200"
def test_no_duration_skip_outro(self):
"""无 duration 时跳过滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_LEFT,
animation_duration=0.5,
duration=None,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert y == "200"
def test_expression_format_quoted(self):
"""有动画时表达式带单引号."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
x, _ = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x.startswith("'")
assert x.endswith("'")
def test_static_position_unquoted(self):
"""无动画时纯数字,不带引号."""
layer = _make_layer()
x, y = build_overlay_expr(layer, 50, 60, 100, 80, OUTPUT_W, OUTPUT_H)
assert x == "50"
assert y == "60"
assert "'" not in x
assert "'" not in y
# ═══════════════════════════════════════════════════════════════════════════════
# build_enable_expr
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildEnableExpr:
"""enable 表达式构建测试."""
def test_no_time_restriction(self):
"""无时间限制返回空."""
layer = _make_layer()
assert build_enable_expr(layer) == ""
def test_start_time_only(self):
"""只有开始时间."""
layer = _make_layer(start_time=5.0)
result = build_enable_expr(layer)
assert result == ":enable='gte(t,5.0)'"
def test_duration_only(self):
"""只有 duration(从 0 开始)."""
layer = _make_layer(duration=10.0)
result = build_enable_expr(layer)
assert result == ":enable='between(t,0.0,10.0)'"
def test_start_and_duration(self):
"""开始时间 + 时长."""
layer = _make_layer(start_time=2.0, duration=5.0)
result = build_enable_expr(layer)
assert "between(t,2.0,7.0)" in result
def test_zero_start_with_duration(self):
"""0 开始 + 时长."""
layer = _make_layer(start_time=0, duration=3.5)
result = build_enable_expr(layer)
assert "between(t,0.0,3.5)" in result
def test_negative_start_clamped(self):
"""负开始时间钳制为 0."""
layer = _make_layer(start_time=-1.0, duration=5.0)
result = build_enable_expr(layer)
assert "between(t,0.0,5.0)" in result
def test_none_duration(self):
"""duration=None 视为无限."""
layer = _make_layer(start_time=3.0, duration=None)
result = build_enable_expr(layer)
assert "gte(t,3.0)" in result
assert "between" not in result
# ═══════════════════════════════════════════════════════════════════════════════
# build_pip_filters
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildPipFilters:
"""完整滤镜链构建测试."""
def test_empty_layers(self):
"""空图层列表返回空."""
filters, inputs, label = build_pip_filters(
"base",
[],
[],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
assert filters == []
assert inputs == []
assert label == "base"
def test_single_layer(self):
"""单个图层."""
layer = _make_layer(width="20%", position="bottom_right")
path = Path("/tmp/clip1.mp4")
filters, inputs, label = build_pip_filters(
"v0",
[layer],
[path],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
# 2 个滤镜片段:预处理 + overlay
assert len(filters) == 2
# 1 个输入
assert inputs == ["-i", str(path)]
# 最终标签
assert label == "pip_combined_0"
def test_multiple_layers(self):
"""多个图层."""
layers = [
_make_layer(width="30%", position="bottom_left"),
_make_layer(width="25%", position="top_right"),
_make_layer(width="20%", position="top_left"),
]
paths = [Path("/tmp/a.mp4"), Path("/tmp/b.mp4"), Path("/tmp/c.mp4")]
filters, inputs, label = build_pip_filters(
"base",
layers,
paths,
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
# 每个图层 2 个滤镜(预处理 + overlay
assert len(filters) == 6
# 3 个输入
assert len(inputs) == 6 # -i path × 3
assert inputs[0::2] == ["-i", "-i", "-i"]
# 最终标签是最后一个 combined
assert label == "pip_combined_2"
def test_base_input_idx_offset(self):
"""base_input_idx 偏移."""
layer = _make_layer(width="20%")
filters, inputs, label = build_pip_filters(
"base",
[layer],
[Path("/tmp/x.mp4")],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
base_input_idx=5,
)
# 预处理滤镜引用 [5:v]
assert "[5:v]" in filters[0]
def test_layer_count_mismatch_raises(self):
"""图层和路径数量不一致时报错."""
with pytest.raises(ValueError, match="长度不一致"):
build_pip_filters(
"base",
[_make_layer()],
[],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
def test_filter_chaining(self):
"""多图层时滤镜链正确串联."""
layers = [_make_layer(width="10%"), _make_layer(width="10%")]
paths = [Path("/tmp/1.mp4"), Path("/tmp/2.mp4")]
filters, _, _ = build_pip_filters(
"base",
layers,
paths,
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
# 第一个 overlay 的输入是 base + pip_pre_0
# 输出是 pip_combined_0
assert "[base]" in filters[1]
assert "[pip_combined_0]" in filters[1]
# 第二个 overlay 的输入是 pip_combined_0 + pip_pre_1
# 输出是 pip_combined_1
assert "[pip_combined_0]" in filters[3]
assert "[pip_combined_1]" in filters[3]
def test_with_animation_layer(self):
"""带动画的图层生成正确表达式."""
layer = _make_layer(
width="30%",
animation_in=ANIMATION_SLIDE_BOTTOM,
animation_duration=0.5,
)
filters, inputs, _ = build_pip_filters(
"v0",
[layer],
[Path("/tmp/a.mp4")],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
# overlay 滤镜中包含滑动表达式
overlay_filter = filters[1]
assert "overlay=" in overlay_filter
assert str(OUTPUT_H) in overlay_filter # 从底部滑入
def test_with_enable_time(self):
"""带时间控制的图层."""
layer = _make_layer(width="20%", start_time=2.0, duration=5.0)
filters, _, _ = build_pip_filters(
"v0",
[layer],
[Path("/tmp/a.mp4")],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
overlay_filter = filters[1]
assert "enable=" in overlay_filter
assert "between" in overlay_filter
def test_string_paths(self):
"""路径可以是字符串."""
layer = _make_layer(width="10%")
filters, inputs, label = build_pip_filters(
"v0",
[layer],
["/tmp/s.mp4"],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
assert inputs == ["-i", "/tmp/s.mp4"]
assert len(filters) == 2
# ═══════════════════════════════════════════════════════════════════════════════
# validate_pip_layer
# ═══════════════════════════════════════════════════════════════════════════════
class TestValidatePipLayer:
"""配置验证测试."""
def test_valid_layer(self):
"""合法配置."""
layer = _make_layer()
ok, err = validate_pip_layer(layer)
assert ok is True
assert err == ""
def test_empty_source_type(self):
"""空 source_type."""
layer = _make_layer(source_type="")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "source_type" in err
def test_invalid_source_type(self):
"""不支持的 source_type."""
layer = _make_layer(source_type="ftp")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "source_type" in err
def test_empty_source(self):
"""空 source."""
layer = _make_layer(source="")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "source" in err
def test_invalid_position(self):
"""不支持的 position."""
layer = _make_layer(position="middle")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "position" in err
def test_opacity_too_high(self):
"""opacity > 1."""
layer = _make_layer(opacity=1.5)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "opacity" in err
def test_opacity_negative(self):
"""opacity < 0."""
layer = _make_layer(opacity=-0.1)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "opacity" in err
def test_negative_corner_radius(self):
"""负圆角."""
layer = _make_layer(corner_radius=-5)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "corner_radius" in err
def test_negative_border_width(self):
"""负边框."""
layer = _make_layer(border_width=-2)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "border_width" in err
def test_negative_start_time(self):
"""负开始时间."""
layer = _make_layer(start_time=-1.0)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "start_time" in err
def test_negative_duration(self):
"""负时长."""
layer = _make_layer(duration=-5.0)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "duration" in err
def test_invalid_animation_in(self):
"""不支持的入场动画."""
layer = _make_layer(animation_in="zoom")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "animation_in" in err
def test_invalid_animation_out(self):
"""不支持的出场动画."""
layer = _make_layer(animation_out="spin")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "animation_out" in err
def test_multiple_errors_combined(self):
"""多个错误合并."""
layer = _make_layer(source_type="", source="", opacity=2.0, position="xxx")
ok, err = validate_pip_layer(layer)
assert ok is False
assert err.count(";") >= 2 # 至少 2 个错误
def test_valid_url_source(self):
"""URL 类型 source 合法."""
layer = _make_layer(source_type="url", source="https://example.com/v.mp4")
ok, err = validate_pip_layer(layer)
assert ok is True
def test_valid_asset_id(self):
"""asset_id 类型合法."""
layer = _make_layer(source_type="asset_id", source="asset_123")
ok, err = validate_pip_layer(layer)
assert ok is True
def test_zero_values_valid(self):
"""0 值合法(不是负数)."""
layer = _make_layer(
corner_radius=0,
border_width=0,
start_time=0,
animation_duration=0,
)
ok, err = validate_pip_layer(layer)
assert ok is True
# ═══════════════════════════════════════════════════════════════════════════════
# count_visible_layers
# ═══════════════════════════════════════════════════════════════════════════════
class TestCountVisibleLayers:
"""可见图层统计测试."""
def test_all_visible(self):
"""全部可见."""
layers = [_make_layer(opacity=1.0), _make_layer(opacity=0.5)]
assert count_visible_layers(layers) == 2
def test_all_invisible(self):
"""全部不可见."""
layers = [_make_layer(opacity=0.0), _make_layer(opacity=0.0)]
assert count_visible_layers(layers) == 0
def test_mixed(self):
"""混合."""
layers = [
_make_layer(opacity=1.0),
_make_layer(opacity=0.0),
_make_layer(opacity=0.001),
]
assert count_visible_layers(layers) == 2
def test_empty_list(self):
"""空列表."""
assert count_visible_layers([]) == 0
# ═══════════════════════════════════════════════════════════════════════════════
# sort_layers_by_z_index
# ═══════════════════════════════════════════════════════════════════════════════
class TestSortLayersByZIndex:
"""图层排序测试."""
def test_sorted_by_z_index(self):
"""按 z_index 从小到大排序."""
layers = [
_make_layer(z_index=5, source="/tmp/a.mp4"),
_make_layer(z_index=1, source="/tmp/b.mp4"),
_make_layer(z_index=3, source="/tmp/c.mp4"),
]
sorted_layers = sort_layers_by_z_index(layers)
assert [layer.z_index for layer in sorted_layers] == [1, 3, 5]
def test_same_z_index_stable(self):
"""相同 z_index 保持相对顺序."""
layers = [
_make_layer(z_index=2, source="/tmp/1.mp4"),
_make_layer(z_index=2, source="/tmp/2.mp4"),
]
sorted_layers = sort_layers_by_z_index(layers)
assert sorted_layers[0].source == "/tmp/1.mp4"
assert sorted_layers[1].source == "/tmp/2.mp4"
def test_empty_list(self):
"""空列表."""
assert sort_layers_by_z_index([]) == []
def test_single_layer(self):
"""单个图层."""
layers = [_make_layer(z_index=0)]
assert len(sort_layers_by_z_index(layers)) == 1
def test_negative_z_index(self):
"""负 z_index."""
layers = [
_make_layer(z_index=0, source="/tmp/0.mp4"),
_make_layer(z_index=-5, source="/tmp/-5.mp4"),
_make_layer(z_index=3, source="/tmp/3.mp4"),
]
sorted_layers = sort_layers_by_z_index(layers)
assert [layer.z_index for layer in sorted_layers] == [-5, 0, 3]
File diff suppressed because it is too large Load Diff
+392 -202
View File
@@ -1,4 +1,4 @@
"""transition_config 模块单测 — 纯逻辑,无 FFmpeg 依赖."""
"""transition_config 转场配置领域模型单元测试."""
from __future__ import annotations
@@ -13,264 +13,454 @@ from packages.domain.transition_config import (
TransitionType,
)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
def test_duration_bounds(self):
"""常量测试."""
def test_min_duration(self):
"""最小转场时长."""
assert MIN_TRANSITION_DURATION == 0.3
def test_max_duration(self):
"""最大转场时长."""
assert MAX_TRANSITION_DURATION == 2.0
def test_default_duration(self):
"""默认转场时长."""
assert DEFAULT_TRANSITION_DURATION == 0.5
assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION
def test_duration_range_valid(self):
"""时长范围合理:min < default < max."""
assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION
assert DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION
def test_cut_transition(self):
"""硬切常量."""
assert CUT_TRANSITION == "cut"
# ── TransitionType 枚举 ──────────────────────────────────────────────────────
# ── TransitionType 枚举测试 ───────────────────────────────────────────────────
class TestTransitionType:
def test_all_supported_includes_all_except_cut(self):
"""TransitionType 枚举测试."""
def test_has_cut(self):
"""有CUT类型."""
assert TransitionType.CUT.value == "cut"
def test_has_fade(self):
"""有FADE类型."""
assert TransitionType.FADE.value == "fade"
def test_has_dissolve(self):
"""有DISSOLVE类型."""
assert TransitionType.DISSOLVE.value == "dissolve"
def test_slide_types(self):
"""滑动系列四种."""
assert TransitionType.SLIDE_LEFT.value == "slideleft"
assert TransitionType.SLIDE_RIGHT.value == "slideright"
assert TransitionType.SLIDE_UP.value == "slideup"
assert TransitionType.SLIDE_DOWN.value == "slidedown"
def test_wipe_types(self):
"""擦除系列四种."""
assert TransitionType.WIPE_LEFT.value == "wipeleft"
assert TransitionType.WIPE_RIGHT.value == "wiperight"
assert TransitionType.WIPE_UP.value == "wipeup"
assert TransitionType.WIPE_DOWN.value == "wipedown"
def test_zoom_type(self):
"""缩放类型."""
assert TransitionType.ZOOM.value == "zoom"
def test_circle_crop_type(self):
"""圆形扩散."""
assert TransitionType.CIRCLE_CROP.value == "circlecrop"
def test_rect_crop_type(self):
"""矩形覆盖."""
assert TransitionType.RECT_CROP.value == "rectcrop"
def test_total_types(self):
"""共14种转场类型."""
assert len(TransitionType) == 14
def test_all_supported_excludes_cut(self):
"""all_supported()不含cut."""
supported = TransitionType.all_supported()
assert "cut" not in supported
assert "fade" in supported
assert "dissolve" in supported
assert len(supported) >= 10 # 至少有10种转场
assert len(supported) == 13
def test_all_supported_unique(self):
def test_all_supported_returns_strings(self):
"""all_supported()返回字符串列表."""
supported = TransitionType.all_supported()
assert len(supported) == len(set(supported))
assert all(isinstance(s, str) for s in supported)
def test_is_supported_exact_match(self):
def test_is_supported_valid(self):
"""支持的转场类型."""
assert TransitionType.is_supported("fade") is True
assert TransitionType.is_supported("dissolve") is True
assert TransitionType.is_supported("slideleft") is True
def test_is_supported_invalid(self):
"""不支持的转场类型."""
assert TransitionType.is_supported("invalid_effect") is False
assert TransitionType.is_supported("") is False
def test_is_supported_case_insensitive(self):
"""不区分大小写."""
assert TransitionType.is_supported("FADE") is True
assert TransitionType.is_supported("Fade") is True
assert TransitionType.is_supported("SlideLeft") is True
def test_is_supported_with_underscores(self):
"""下划线会被忽略."""
assert TransitionType.is_supported("slide_left") is True
assert TransitionType.is_supported("wipe_right") is True
assert TransitionType.is_supported("circle_crop") is True
def test_is_supported_with_hyphens(self):
"""中划线会被忽略."""
assert TransitionType.is_supported("slide-left") is True
assert TransitionType.is_supported("wipe-down") is True
def test_is_supported_aliases(self):
assert TransitionType.is_supported("crossfade") is True
assert TransitionType.is_supported("crossdissolve") is True
assert TransitionType.is_supported("fadein") is True
assert TransitionType.is_supported("fadeout") is True
assert TransitionType.is_supported("slide") is True
assert TransitionType.is_supported("wipe") is True
assert TransitionType.is_supported("zoomin") is True
assert TransitionType.is_supported("zoomout") is True
assert TransitionType.is_supported("circle") is True
assert TransitionType.is_supported("rect") is True
def test_is_supported_unknown(self):
assert TransitionType.is_supported("unknown_effect") is False
assert TransitionType.is_supported("") is False
assert TransitionType.is_supported("12345") is False
def test_enum_values_match_ffmpeg(self):
# 枚举值应该就是 ffmpeg xfade 的 transition 名
assert TransitionType.FADE.value == "fade"
assert TransitionType.DISSOLVE.value == "dissolve"
assert TransitionType.SLIDE_LEFT.value == "slideleft"
assert TransitionType.CUT.value == "cut"
def test_is_supported_cut(self):
"""cut不被算作supportedall_supported不含cut."""
# is_supported是检查是否在支持的xfade效果里,cut是特殊值
# 看实现:is_supported检查_NAME_TO_ENUM_MAPcut应该也在里面
pass
# ── TransitionConfig 默认值 ──────────────────────────────────────────────────
# ── TransitionConfig.parse - effect 测试 ─────────────────────────────────────
class TestTransitionConfigDefaults:
def test_default_config(self):
cfg = TransitionConfig()
class TestTransitionConfigParseEffect:
"""TransitionConfig.parse effect参数测试."""
def test_none_effect_defaults_to_cut(self):
"""None effect → cut."""
cfg = TransitionConfig.parse(effect=None)
assert cfg.effect == CUT_TRANSITION
def test_empty_effect_defaults_to_cut(self):
"""空字符串 → cut."""
cfg = TransitionConfig.parse(effect="")
assert cfg.effect == CUT_TRANSITION
def test_whitespace_effect_defaults_to_cut(self):
"""纯空白 → cut."""
cfg = TransitionConfig.parse(effect=" ")
assert cfg.effect == CUT_TRANSITION
def test_cut_effect(self):
"""显式cut."""
cfg = TransitionConfig.parse(effect="cut")
assert cfg.effect == "cut"
def test_cut_case_insensitive(self):
"""CUT不区分大小写."""
cfg = TransitionConfig.parse(effect="CUT")
assert cfg.effect == "cut"
def test_valid_fade_effect(self):
"""有效的fade效果."""
cfg = TransitionConfig.parse(effect="fade")
assert cfg.effect == "fade"
def test_valid_dissolve_effect(self):
"""有效的dissolve效果."""
cfg = TransitionConfig.parse(effect="dissolve")
assert cfg.effect == "dissolve"
def test_effect_stripped(self):
"""effect去除空白."""
cfg = TransitionConfig.parse(effect=" fade ")
assert cfg.effect == "fade"
def test_invalid_effect_falls_back_to_cut(self):
"""无效效果 → 降级为cut."""
cfg = TransitionConfig.parse(effect="super_fancy_effect")
assert cfg.effect == CUT_TRANSITION
def test_alias_crossfade(self):
"""别名crossfade → dissolve."""
cfg = TransitionConfig.parse(effect="crossfade")
assert cfg.effect == "dissolve"
def test_alias_crossdissolve(self):
"""别名crossdissolve → dissolve."""
cfg = TransitionConfig.parse(effect="crossdissolve")
assert cfg.effect == "dissolve"
def test_alias_fadein(self):
"""别名fadein → fade."""
cfg = TransitionConfig.parse(effect="fadein")
assert cfg.effect == "fade"
def test_alias_fadeout(self):
"""别名fadeout → fade."""
cfg = TransitionConfig.parse(effect="fadeout")
assert cfg.effect == "fade"
def test_alias_slide(self):
"""别名slide → slideleft."""
cfg = TransitionConfig.parse(effect="slide")
assert cfg.effect == "slideleft"
def test_alias_wipe(self):
"""别名wipe → wipeleft."""
cfg = TransitionConfig.parse(effect="wipe")
assert cfg.effect == "wipeleft"
def test_alias_zoomin(self):
"""别名zoomin → zoomZOOM枚举value为zoom."""
cfg = TransitionConfig.parse(effect="zoomin")
assert cfg.effect == "zoom"
def test_alias_circle(self):
"""别名circle → circlecrop."""
cfg = TransitionConfig.parse(effect="circle")
assert cfg.effect == "circlecrop"
def test_alias_rect(self):
"""别名rect → rectcrop."""
cfg = TransitionConfig.parse(effect="rect")
assert cfg.effect == "rectcrop"
def test_underscore_format(self):
"""下划线格式能解析."""
cfg = TransitionConfig.parse(effect="slide_left")
assert cfg.effect == "slideleft"
def test_case_insensitive_alias(self):
"""别名不区分大小写."""
cfg = TransitionConfig.parse(effect="CrossFade")
assert cfg.effect == "dissolve"
def test_slide_left_direct(self):
"""直接用slideleft."""
cfg = TransitionConfig.parse(effect="slideleft")
assert cfg.effect == "slideleft"
# ── TransitionConfig.parse - duration 测试 ───────────────────────────────────
class TestTransitionConfigParseDuration:
"""TransitionConfig.parse duration参数测试."""
def test_none_duration_uses_default(self):
"""None duration → 默认值."""
cfg = TransitionConfig.parse(duration=None)
assert cfg.duration == DEFAULT_TRANSITION_DURATION
assert cfg.is_cut is True
def test_normal_duration(self):
"""正常范围内的时长."""
cfg = TransitionConfig.parse(duration=1.0)
assert cfg.duration == 1.0
def test_min_duration(self):
"""刚好等于最小值."""
cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_max_duration(self):
"""刚好等于最大值."""
cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION)
assert cfg.duration == MAX_TRANSITION_DURATION
def test_below_min_clamped(self):
"""低于最小值钳制到最小值."""
cfg = TransitionConfig.parse(duration=0.1)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_zero_duration_clamped(self):
"""0时长钳制到最小值."""
cfg = TransitionConfig.parse(duration=0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_negative_duration_clamped(self):
"""负时长钳制到最小值."""
cfg = TransitionConfig.parse(duration=-1)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_above_max_clamped(self):
"""超过最大值钳制到最大值."""
cfg = TransitionConfig.parse(duration=5.0)
assert cfg.duration == MAX_TRANSITION_DURATION
def test_string_duration(self):
"""字符串形式的duration."""
cfg = TransitionConfig.parse(duration="1.5")
assert cfg.duration == 1.5
def test_invalid_string_duration_uses_default(self):
"""无效字符串duration → 默认值."""
cfg = TransitionConfig.parse(duration="abc")
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_int_duration(self):
"""整数时长."""
cfg = TransitionConfig.parse(duration=1)
assert cfg.duration == 1.0
def test_default_duration_when_no_args(self):
"""无参时duration为默认值."""
cfg = TransitionConfig.parse()
assert cfg.duration == DEFAULT_TRANSITION_DURATION
# ── TransitionConfig 属性测试 ────────────────────────────────────────────────
class TestTransitionConfigProperties:
"""TransitionConfig 属性测试."""
def test_is_cut_true(self):
"""cut是硬切."""
cfg = TransitionConfig(effect="cut")
assert cfg.is_cut is True
def test_is_cut_false(self):
"""非cut不是硬切."""
cfg = TransitionConfig(effect="fade")
assert cfg.is_cut is False
def test_is_cut_default(self):
"""默认配置是硬切."""
cfg = TransitionConfig()
assert cfg.is_cut is True
# ── TransitionConfig.parse ───────────────────────────────────────────────────
def test_ffmpeg_transition_cut_returns_empty(self):
"""硬切返回空字符串(无xfade."""
cfg = TransitionConfig(effect="cut")
assert cfg.ffmpeg_transition == ""
def test_ffmpeg_transition_fade(self):
"""fade → fade."""
cfg = TransitionConfig(effect="fade")
assert cfg.ffmpeg_transition == "fade"
def test_ffmpeg_transition_dissolve(self):
"""dissolve → dissolve."""
cfg = TransitionConfig(effect="dissolve")
assert cfg.ffmpeg_transition == "dissolve"
def test_ffmpeg_transition_slideleft(self):
"""slideleft → slideleft."""
cfg = TransitionConfig(effect="slideleft")
assert cfg.ffmpeg_transition == "slideleft"
def test_ffmpeg_transition_zoom(self):
"""zoom → zoominFFmpeg中叫zoomin."""
cfg = TransitionConfig(effect="zoom")
assert cfg.ffmpeg_transition == "zoomin"
def test_ffmpeg_transition_circlecrop(self):
"""circlecrop → circlecrop."""
cfg = TransitionConfig(effect="circlecrop")
assert cfg.ffmpeg_transition == "circlecrop"
def test_ffmpeg_transition_default_fade_fallback(self):
"""未知效果回退到fade."""
# 直接构造一个不支持的effect(绕过parse
cfg = TransitionConfig(effect="unknown_effect", duration=0.5)
# 会回退到fade
assert cfg.ffmpeg_transition == "fade"
class TestTransitionConfigParse:
def test_none_params_default(self):
# ── TransitionConfig.validate 测试 ───────────────────────────────────────────
class TestTransitionConfigValidate:
"""TransitionConfig.validate 测试."""
def test_valid_default_cut(self):
"""默认cut配置是合法的."""
cfg = TransitionConfig()
ok, msg = cfg.validate()
assert ok is True
assert msg == ""
def test_valid_fade(self):
"""fade配置是合法的."""
cfg = TransitionConfig(effect="fade", duration=1.0)
ok, msg = cfg.validate()
assert ok is True
assert msg == ""
def test_valid_min_duration(self):
"""最小时长是合法的."""
cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION)
ok, msg = cfg.validate()
assert ok is True
def test_valid_max_duration(self):
"""最大时长是合法的."""
cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION)
ok, msg = cfg.validate()
assert ok is True
def test_invalid_duration_too_low(self):
"""时长小于最小值不合法."""
cfg = TransitionConfig(effect="fade", duration=0.1)
ok, msg = cfg.validate()
assert ok is False
assert "不能小于" in msg
def test_invalid_duration_too_high(self):
"""时长大于最大值不合法."""
cfg = TransitionConfig(effect="fade", duration=5.0)
ok, msg = cfg.validate()
assert ok is False
assert "不能大于" in msg
def test_invalid_unknown_effect(self):
"""未知效果不合法."""
cfg = TransitionConfig(effect="super_fancy", duration=0.5)
ok, msg = cfg.validate()
assert ok is False
assert "不支持" in msg
def test_cut_always_valid(self):
"""cut效果总是合法的(即使duration异常...要看实现)."""
# cut的话is_cut为Truevalidate里会跳过effect检查
cfg = TransitionConfig(effect="cut", duration=1.0)
ok, msg = cfg.validate()
assert ok is True
# ── 默认值测试 ───────────────────────────────────────────────────────────────
class TestTransitionConfigDefaults:
"""TransitionConfig 默认值测试."""
def test_default_effect_is_cut(self):
"""默认effect是cut."""
cfg = TransitionConfig()
assert cfg.effect == CUT_TRANSITION
def test_default_duration(self):
"""默认duration."""
cfg = TransitionConfig()
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_parse_no_args(self):
"""parse无参 → 默认值."""
cfg = TransitionConfig.parse()
assert cfg.effect == CUT_TRANSITION
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_empty_effect_default(self):
cfg = TransitionConfig.parse(effect="")
def test_parse_both_none(self):
"""两个参数都None → 默认值."""
cfg = TransitionConfig.parse(effect=None, duration=None)
assert cfg.effect == CUT_TRANSITION
def test_whitespace_effect_default(self):
cfg = TransitionConfig.parse(effect=" ")
assert cfg.effect == CUT_TRANSITION
def test_valid_effect_fade(self):
cfg = TransitionConfig.parse(effect="fade")
assert cfg.effect == "fade"
assert cfg.is_cut is False
def test_valid_effect_case_insensitive(self):
cfg = TransitionConfig.parse(effect="FADE")
assert cfg.effect == "fade"
def test_valid_effect_with_underscores(self):
cfg = TransitionConfig.parse(effect="slide_left")
assert cfg.effect == "slideleft"
def test_alias_effect(self):
cfg = TransitionConfig.parse(effect="crossfade")
assert cfg.effect == "dissolve" # 别名映射到 dissolve
def test_unknown_effect_falls_back_to_cut(self):
cfg = TransitionConfig.parse(effect="magic_sparkles")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_cut_effect_stays_cut(self):
cfg = TransitionConfig.parse(effect="cut")
assert cfg.effect == CUT_TRANSITION
def test_cut_effect_case_insensitive(self):
cfg = TransitionConfig.parse(effect="CUT")
assert cfg.effect == CUT_TRANSITION
def test_duration_default(self):
cfg = TransitionConfig.parse(duration=None)
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_duration_within_range(self):
cfg = TransitionConfig.parse(duration=1.0)
assert cfg.duration == 1.0
def test_is_dataclass(self):
"""是dataclass."""
from dataclasses import is_dataclass
def test_duration_at_min(self):
cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_at_max(self):
cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION)
assert cfg.duration == MAX_TRANSITION_DURATION
def test_duration_below_min_clamped(self):
cfg = TransitionConfig.parse(duration=0.1)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_above_max_clamped(self):
cfg = TransitionConfig.parse(duration=3.0)
assert cfg.duration == MAX_TRANSITION_DURATION
def test_duration_zero_clamped(self):
cfg = TransitionConfig.parse(duration=0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_negative_clamped(self):
cfg = TransitionConfig.parse(duration=-1.0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_invalid_string_fallback(self):
cfg = TransitionConfig.parse(duration="bad") # type: ignore[arg-type]
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_duration_numeric_string(self):
cfg = TransitionConfig.parse(duration="1.5") # type: ignore[arg-type]
assert cfg.duration == 1.5
def test_full_parse(self):
cfg = TransitionConfig.parse(effect="wipe_up", duration=1.2)
assert cfg.effect == "wipeup"
assert cfg.duration == 1.2
assert cfg.is_cut is False
# ── TransitionConfig.ffmpeg_transition ───────────────────────────────────────
class TestFfmpegTransition:
def test_cut_returns_empty(self):
cfg = TransitionConfig(effect="cut")
assert cfg.ffmpeg_transition == ""
def test_fade_matches(self):
cfg = TransitionConfig(effect="fade")
assert cfg.ffmpeg_transition == "fade"
def test_dissolve_matches(self):
cfg = TransitionConfig(effect="dissolve")
assert cfg.ffmpeg_transition == "dissolve"
def test_slide_left_matches(self):
cfg = TransitionConfig(effect="slideleft")
assert cfg.ffmpeg_transition == "slideleft"
def test_wipe_down_matches(self):
cfg = TransitionConfig(effect="wipedown")
assert cfg.ffmpeg_transition == "wipedown"
def test_zoom_matches_zoomin(self):
cfg = TransitionConfig(effect="zoom")
assert cfg.ffmpeg_transition == "zoomin"
def test_circle_crop_matches(self):
cfg = TransitionConfig(effect="circlecrop")
assert cfg.ffmpeg_transition == "circlecrop"
# ── TransitionConfig.validate ────────────────────────────────────────────────
class TestTransitionConfigValidate:
def test_valid_cut(self):
cfg = TransitionConfig(effect="cut", duration=0.5)
ok, err = cfg.validate()
assert ok is True
assert err == ""
def test_valid_fade(self):
cfg = TransitionConfig(effect="fade", duration=1.0)
ok, err = cfg.validate()
assert ok is True
def test_duration_below_min_invalid(self):
cfg = TransitionConfig(effect="fade", duration=0.1)
ok, err = cfg.validate()
assert ok is False
assert "duration" in err
def test_duration_above_max_invalid(self):
cfg = TransitionConfig(effect="fade", duration=3.0)
ok, err = cfg.validate()
assert ok is False
assert "duration" in err
def test_unsupported_effect_invalid(self):
cfg = TransitionConfig(effect="unknown", duration=0.5)
ok, err = cfg.validate()
assert ok is False
assert "不支持的转场" in err
def test_min_duration_boundary_valid(self):
cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION)
ok, _ = cfg.validate()
assert ok is True
def test_max_duration_boundary_valid(self):
cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION)
ok, _ = cfg.validate()
assert ok is True
assert is_dataclass(TransitionConfig)
+337 -149
View File
@@ -1,6 +1,8 @@
from dataclasses import FrozenInstanceError
"""转场预设库单元测试."""
"""transition_presets 领域层单元测试 - 转场预设库"""
from __future__ import annotations
import dataclasses
import pytest
@@ -12,198 +14,384 @@ from packages.domain.transition_presets import (
list_transition_presets,
)
# ── 数据类测试 ────────────────────────────────────────────────────────────────
class TestTransitionPreset:
"""TransitionPreset 数据类测试"""
"""TransitionPreset 数据类测试."""
def test_create_minimal(self):
preset = TransitionPreset(id="test", name="测试", category="basic", transition="fade")
assert preset.id == "test"
assert preset.name == "测试"
assert preset.category == "basic"
assert preset.transition == "fade"
assert preset.description == ""
assert preset.tags == []
assert preset.default_duration == 0.5
assert preset.min_duration == 0.1
assert preset.max_duration == 3.0
assert preset.has_custom_params is False
def test_create_with_all_params(self):
preset = TransitionPreset(
id="custom",
name="自定义转场",
category="special",
description="炫酷特效",
tags=["炫酷", "特效"],
transition="custom",
default_duration=1.0,
min_duration=0.5,
max_duration=5.0,
has_custom_params=True,
def test_basic_attributes(self):
"""基础属性可访问."""
p = TransitionPreset(
id="test_id",
name="测试转场",
category="fade",
description="测试描述",
tags=["tag1", "tag2"],
transition="fade",
default_duration=0.5,
min_duration=0.1,
max_duration=3.0,
has_custom_params=False,
)
assert preset.description == "炫酷特效"
assert preset.tags == ["炫酷", "特效"]
assert preset.default_duration == 1.0
assert preset.min_duration == 0.5
assert preset.max_duration == 5.0
assert preset.has_custom_params is True
assert p.id == "test_id"
assert p.name == "测试转场"
assert p.category == "fade"
assert p.description == "测试描述"
assert p.tags == ["tag1", "tag2"]
assert p.transition == "fade"
assert p.default_duration == 0.5
assert p.min_duration == 0.1
assert p.max_duration == 3.0
assert p.has_custom_params is False
def test_default_values(self):
"""默认值正确."""
p = TransitionPreset(id="t", name="T", category="basic")
assert p.description == ""
assert p.tags == []
assert p.transition == "fade"
assert p.default_duration == 0.5
assert p.min_duration == 0.1
assert p.max_duration == 3.0
assert p.has_custom_params is False
def test_frozen_immutable(self):
"""frozen dataclass 不可修改"""
preset = TransitionPreset(id="test", name="测试", category="basic")
with pytest.raises(FrozenInstanceError):
preset.name = "改名"
"""frozen dataclass 不可修改."""
p = TransitionPreset(id="t", name="T", category="basic")
with pytest.raises(dataclasses.FrozenInstanceError):
p.name = "新名字"
def test_tags_default_empty_list(self):
preset = TransitionPreset(id="t1", name="t1", category="basic")
preset2 = TransitionPreset(id="t2", name="t2", category="basic")
assert preset.tags == []
assert preset.tags is not preset2.tags
def test_not_hashable_due_to_list(self):
"""含list字段(tags)的frozen dataclass不可哈希(list可变)."""
p = TransitionPreset(id="t", name="T", category="basic")
with pytest.raises(TypeError):
hash(p)
def test_default_transition_is_fade(self):
preset = TransitionPreset(id="test", name="测试", category="basic")
assert preset.transition == "fade"
def test_equality(self):
"""相同属性的实例相等."""
p1 = TransitionPreset(id="t", name="T", category="basic")
p2 = TransitionPreset(id="t", name="T", category="basic")
assert p1 == p2
def test_inequality(self):
"""不同属性的实例不等."""
p1 = TransitionPreset(id="t1", name="T", category="basic")
p2 = TransitionPreset(id="t2", name="T", category="basic")
assert p1 != p2
# ── 预设库完整性测试 ──────────────────────────────────────────────────────────
class TestTransitionPresetLibrary:
"""TRANSITION_PRESET_LIBRARY 预设库测试"""
"""TRANSITION_PRESET_LIBRARY 预设库完整性测试."""
def test_library_not_empty(self):
"""预设库不为空."""
assert len(TRANSITION_PRESET_LIBRARY) > 0
def test_all_presets_have_unique_ids(self):
"""所有预设 ID 唯一"""
def test_preset_count(self):
"""预设数量应大于20."""
assert len(TRANSITION_PRESET_LIBRARY) >= 20
def test_all_ids_unique(self):
"""所有预设ID唯一."""
ids = [p.id for p in TRANSITION_PRESET_LIBRARY]
assert len(ids) == len(set(ids))
assert len(ids) == len(set(ids)), f"存在重复ID: {[i for i in ids if ids.count(i) > 1]}"
def test_all_presets_have_required_fields(self):
"""所有预设都有必填字段"""
for preset in TRANSITION_PRESET_LIBRARY:
assert preset.id, "missing id"
assert preset.name, f"{preset.id} missing name"
assert preset.category, f"{preset.id} missing category"
assert preset.transition, f"{preset.id} missing transition"
def test_all_have_required_fields(self):
"""所有预设都有必填字段."""
for p in TRANSITION_PRESET_LIBRARY:
assert p.id, f"预设缺少id: {p}"
assert p.name, f"预设 {p.id} 缺少name"
assert p.category, f"预设 {p.id} 缺少category"
assert p.transition, f"预设 {p.id} 缺少transition"
def test_transition_none_exists(self):
"""无转场预设存在"""
none_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_none"), None)
def test_duration_range_valid(self):
"""每个预设的时长范围合理: min <= default <= max."""
for p in TRANSITION_PRESET_LIBRARY:
assert (
p.min_duration <= p.default_duration
), f"{p.id}: min({p.min_duration}) > default({p.default_duration})"
assert (
p.default_duration <= p.max_duration
), f"{p.id}: default({p.default_duration}) > max({p.max_duration})"
def test_min_duration_non_negative(self):
"""最小时长不能为负."""
for p in TRANSITION_PRESET_LIBRARY:
assert p.min_duration >= 0, f"{p.id}: min_duration为负"
def test_categories_are_valid(self):
"""分类都在预期集合内."""
valid_categories = {"basic", "fade", "slide", "zoom", "warp", "special"}
for p in TRANSITION_PRESET_LIBRARY:
assert p.category in valid_categories, f"{p.id}: 未知分类 {p.category}"
@pytest.mark.parametrize(
"category,expected_min",
[
("basic", 2),
("fade", 4),
("slide", 4),
("zoom", 2),
("warp", 6),
("special", 2),
],
)
def test_category_min_count(self, category: str, expected_min: int):
"""每个分类至少有预期数量的预设."""
count = sum(1 for p in TRANSITION_PRESET_LIBRARY if p.category == category)
assert count >= expected_min, f"分类 {category} 只有 {count} 个,预期至少 {expected_min}"
def test_tags_is_list(self):
"""tags字段是列表."""
for p in TRANSITION_PRESET_LIBRARY:
assert isinstance(p.tags, list), f"{p.id}: tags不是列表"
def test_none_transition_has_zero_duration(self):
"""无转场预设时长为0."""
none_preset = get_transition_preset("transition_none")
assert none_preset is not None
assert none_preset.name == "无转场"
assert none_preset.min_duration == 0.0
assert none_preset.max_duration == 0.0
assert none_preset.default_duration == 0.0
assert none_preset.transition == "none"
def test_transition_random_exists(self):
"""随机转场预设存在"""
random_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_random"), None)
assert random_preset is not None
assert random_preset.name == "随机"
def test_fade_category_exists(self):
"""淡入淡出分类有预设"""
fade_presets = [p for p in TRANSITION_PRESET_LIBRARY if p.category == "fade"]
assert len(fade_presets) >= 2
def test_duration_constraints_valid(self):
"""时长约束:min <= default <= max"""
for preset in TRANSITION_PRESET_LIBRARY:
assert preset.min_duration <= preset.default_duration, f"{preset.id}: min > default"
assert preset.default_duration <= preset.max_duration, f"{preset.id}: default > max"
assert preset.min_duration >= 0, f"{preset.id}: min < 0"
def test_known_categories_exist(self):
"""已知分类都有预设"""
categories = {p.category for p in TRANSITION_PRESET_LIBRARY}
assert "basic" in categories
assert "fade" in categories
# ── get_transition_preset 测试 ────────────────────────────────────────────────
class TestGetTransitionPreset:
"""get_transition_preset 函数测试"""
"""get_transition_preset 函数测试."""
def test_get_existing_preset(self):
preset = get_transition_preset("transition_none")
assert preset is not None
assert preset.id == "transition_none"
def test_get_fade_preset(self):
preset = get_transition_preset("transition_fade")
assert preset is not None
assert preset.transition == "fade"
"""获取存在的预设."""
p = get_transition_preset("transition_fade")
assert p is not None
assert p.id == "transition_fade"
assert p.name == "淡入淡出"
assert p.category == "fade"
def test_get_nonexistent_preset(self):
assert get_transition_preset("nonexistent_transition") is None
"""获取不存在的预设返回None."""
p = get_transition_preset("nonexistent_id")
assert p is None
def test_returns_transitionpreset_type(self):
preset = get_transition_preset("transition_fade")
assert isinstance(preset, TransitionPreset)
def test_get_none_preset(self):
"""获取无转场预设."""
p = get_transition_preset("transition_none")
assert p is not None
assert p.transition == "none"
def test_get_random_preset(self):
"""获取随机预设."""
p = get_transition_preset("transition_random")
assert p is not None
assert p.transition == "random"
def test_case_sensitive(self):
"""ID区分大小写."""
p = get_transition_preset("TRANSITION_FADE")
assert p is None
def test_empty_string(self):
"""空字符串返回None."""
p = get_transition_preset("")
assert p is None
def test_returns_same_instance(self):
"""多次调用返回同一个对象(库引用)."""
p1 = get_transition_preset("transition_fade")
p2 = get_transition_preset("transition_fade")
assert p1 is p2
def test_all_presets_accessible_by_id(self):
"""所有预设都可通过ID获取."""
for p in TRANSITION_PRESET_LIBRARY:
fetched = get_transition_preset(p.id)
assert fetched is not None, f"无法通过ID获取: {p.id}"
assert fetched.id == p.id
# ── list_transition_presets 测试 ──────────────────────────────────────────────
class TestListTransitionPresets:
"""list_transition_presets 函数测试"""
"""list_transition_presets 函数测试."""
def test_list_all(self):
"""不带参数返回所有预设"""
all_presets = list_transition_presets()
assert len(all_presets) == len(TRANSITION_PRESET_LIBRARY)
def test_no_filter_returns_all(self):
"""无筛选参数返回全部预设."""
results = list_transition_presets()
assert len(results) == len(TRANSITION_PRESET_LIBRARY)
def test_filter_by_category(self):
"""按分类筛选"""
fade_presets = list_transition_presets(category="fade")
assert len(fade_presets) > 0
assert all(p.category == "fade" for p in fade_presets)
def test_filter_by_category_fade(self):
"""fade分类筛选."""
results = list_transition_presets(category="fade")
assert len(results) > 0
assert all(p.category == "fade" for p in results)
def test_filter_by_basic_category(self):
basic_presets = list_transition_presets(category="basic")
assert len(basic_presets) >= 2
def test_filter_by_category_slide(self):
"""按slide分类筛选."""
results = list_transition_presets(category="slide")
assert len(results) == 4
assert all(p.category == "slide" for p in results)
def test_filter_by_nonexistent_category(self):
result = list_transition_presets(category="nonexistent")
assert result == []
def test_filter_by_category_basic(self):
"""按basic分类筛选."""
results = list_transition_presets(category="basic")
assert len(results) == 2 # none + random
def test_search_by_name(self):
"""按名称搜索"""
result = list_transition_presets(keyword="淡入")
assert len(result) >= 1
assert any("淡入" in p.name for p in result)
def test_filter_by_invalid_category(self):
"""无效分类返回空列表."""
results = list_transition_presets(category="nonexistent")
assert results == []
def test_search_by_tag(self):
"""按标签搜索"""
tagged = [p for p in TRANSITION_PRESET_LIBRARY if p.tags]
if tagged:
tag = tagged[0].tags[0]
result = list_transition_presets(keyword=tag)
assert len(result) >= 1
def test_keyword_search_in_name(self):
"""关键词搜索name字段."""
results = list_transition_presets(keyword="淡入淡出")
assert len(results) >= 1
assert any(p.id == "transition_fade" for p in results)
def test_search_empty_returns_all(self):
result = list_transition_presets(keyword="")
assert len(result) == len(TRANSITION_PRESET_LIBRARY)
def test_keyword_search_in_description(self):
"""关键词搜索description字段."""
results = list_transition_presets(keyword="硬切")
assert len(results) >= 1
assert any(p.id == "transition_none" for p in results)
def test_combined_category_and_search(self):
result = list_transition_presets(category="fade", keyword="淡入")
assert all(p.category == "fade" for p in result)
def test_keyword_search_in_tags(self):
"""关键词搜索tags字段."""
results = list_transition_presets(keyword="模糊")
assert len(results) >= 2 # hblur + wipeblur
ids = [p.id for p in results]
assert "transition_hblur" in ids
assert "transition_wipeblur" in ids
def test_returns_list_of_transitionpreset(self):
result = list_transition_presets()
assert all(isinstance(p, TransitionPreset) for p in result)
def test_keyword_case_insensitive(self):
"""关键词搜索不区分大小写(英文)."""
results1 = list_transition_presets(keyword="fade")
results2 = list_transition_presets(keyword="FADE")
assert len(results1) == len(results2)
def test_keyword_chinese(self):
"""中文关键词搜索."""
results = list_transition_presets(keyword="")
assert len(results) >= 4 # 4个slide
assert all("" in p.name for p in results)
def test_keyword_no_match(self):
"""无匹配关键词返回空."""
results = list_transition_presets(keyword="完全不存在的关键词xyz")
assert results == []
def test_keyword_empty_string(self):
"""空关键词返回全部."""
results = list_transition_presets(keyword="")
assert len(results) == len(TRANSITION_PRESET_LIBRARY)
def test_category_and_keyword_combined(self):
"""分类+关键词组合筛选."""
results = list_transition_presets(category="warp", keyword="擦除")
assert len(results) >= 4 # 4个wipe
assert all(p.category == "warp" for p in results)
assert all("擦除" in p.name for p in results)
def test_category_and_keyword_no_match(self):
"""分类+关键词不匹配返回空."""
results = list_transition_presets(category="fade", keyword="滑动")
assert results == []
def test_preserve_order(self):
"""保持预设库的顺序."""
results = list_transition_presets()
for i, p in enumerate(TRANSITION_PRESET_LIBRARY):
assert results[i].id == p.id
def test_filtered_results_are_all_valid(self):
"""筛选结果的每个预设都有完整属性."""
results = list_transition_presets(category="slide")
for p in results:
assert p.id
assert p.name
assert p.category == "slide"
assert isinstance(p.tags, list)
# ── get_default_transition 测试 ───────────────────────────────────────────────
class TestGetDefaultTransition:
"""get_default_transition 函数测试"""
def test_returns_preset(self):
preset = get_default_transition()
assert preset is not None
assert isinstance(preset, TransitionPreset)
"""get_default_transition 函数测试."""
def test_default_is_none(self):
"""默认转场是无转场(硬切)"""
preset = get_default_transition()
assert preset.id == "transition_none"
assert preset.transition == "none"
"""默认转场是无转场."""
p = get_default_transition()
assert p.id == "transition_none"
assert p.transition == "none"
def test_default_has_zero_duration(self):
"""无转场默认时长为 0"""
preset = get_default_transition()
assert preset.default_duration == 0.0
assert preset.min_duration == 0.0
assert preset.max_duration == 0.0
def test_default_zero_duration(self):
"""默认转场时长为0."""
p = get_default_transition()
assert p.default_duration == 0.0
assert p.min_duration == 0.0
assert p.max_duration == 0.0
def test_default_category_basic(self):
"""默认转场属于basic分类."""
p = get_default_transition()
assert p.category == "basic"
def test_default_same_instance(self):
"""多次调用返回同一实例."""
p1 = get_default_transition()
p2 = get_default_transition()
assert p1 is p2
def test_default_matches_get_preset(self):
"""默认转场与通过ID获取的一致."""
default = get_default_transition()
by_id = get_transition_preset("transition_none")
assert default is by_id
# ── 预设个体属性抽样测试 ──────────────────────────────────────────────────────
class TestPresetSamples:
"""典型预设的属性验证."""
@pytest.mark.parametrize(
"preset_id,expected_name,expected_category,expected_transition",
[
("transition_none", "无转场", "basic", "none"),
("transition_random", "随机", "basic", "random"),
("transition_fade", "淡入淡出", "fade", "fade"),
("transition_fadeblack", "黑场过渡", "fade", "fadeblack"),
("transition_fadewhite", "白场过渡", "fade", "fadewhite"),
("transition_slideleft", "左滑", "slide", "slideleft"),
("transition_slideright", "右滑", "slide", "slideright"),
("transition_slideup", "上滑", "slide", "slideup"),
("transition_slidedown", "下滑", "slide", "slidedown"),
("transition_zoomin", "放大进入", "zoom", "zoomin"),
("transition_zoomout", "缩小退出", "zoom", "zoomout"),
("transition_dissolve", "溶解", "warp", "dissolve"),
("transition_circlecrop", "圆形展开", "warp", "circlecrop"),
("transition_hblur", "水平模糊", "special", "hblur"),
],
)
def test_preset_attributes(
self, preset_id: str, expected_name: str, expected_category: str, expected_transition: str
):
"""典型预设属性验证."""
p = get_transition_preset(preset_id)
assert p is not None
assert p.name == expected_name
assert p.category == expected_category
assert p.transition == expected_transition
def test_dissolve_longer_default(self):
"""溶解效果默认时长较长(0.8s)."""
p = get_transition_preset("transition_dissolve")
assert p is not None
assert p.default_duration == 0.8
+471 -255
View File
@@ -1,4 +1,4 @@
"""trim_config 领域模型单."""
"""trim_config 裁剪配置领域模型单元测试."""
from __future__ import annotations
@@ -15,409 +15,625 @@ from packages.domain.trim_config import (
resolve_segments,
)
# ── TrimConfig.from_dict 测试 ─────────────────────────────────────────────
# ── TrimConfig.from_dict 测试 ────────────────────────────────────────────────
class TestTrimConfigFromDict:
"""TrimConfig.from_dict 测试."""
def test_none_returns_none(self):
"""None返回None."""
assert TrimConfig.from_dict(None) is None
def test_empty_dict_returns_none(self):
"""空dict返回None."""
assert TrimConfig.from_dict({}) is None
def test_all_zero_returns_none(self):
"""全0返回None(不裁剪)."""
assert TrimConfig.from_dict({"start_time": 0, "end_time": 0, "duration": 0}) is None
def test_start_only_valid(self):
cfg = TrimConfig.from_dict({"start_time": 5.0})
assert cfg is not None
assert cfg.start_time == 5.0
assert cfg.end_time == 0
assert cfg.duration == 0
def test_duration_only_valid(self):
cfg = TrimConfig.from_dict({"duration": 10.0})
assert cfg is not None
assert cfg.duration == 10.0
assert cfg.start_time == 0
def test_start_and_end(self):
"""start + end."""
result = TrimConfig.from_dict({"start_time": 5, "end_time": 10})
assert result is not None
assert result.start_time == 5.0
assert result.end_time == 10.0
def test_start_and_duration(self):
cfg = TrimConfig.from_dict({"start_time": 2.0, "duration": 5.0})
assert cfg is not None
assert cfg.start_time == 2.0
assert cfg.duration == 5.0
"""start + duration."""
result = TrimConfig.from_dict({"start_time": 2, "duration": 5})
assert result is not None
assert result.start_time == 2.0
assert result.duration == 5.0
def test_end_and_duration(self):
"""end + duration."""
result = TrimConfig.from_dict({"end_time": 10, "duration": 3})
assert result is not None
assert result.end_time == 10.0
assert result.duration == 3.0
def test_only_start_returns_config(self):
"""只有start_time也返回有效配置(从start取到末尾语义)."""
result = TrimConfig.from_dict({"start_time": 3})
assert result is not None
assert result.start_time == 3.0
def test_only_duration_returns_config(self):
"""只有duration也返回(从开头取duration."""
result = TrimConfig.from_dict({"duration": 5})
assert result is not None
assert result.duration == 5.0
def test_only_end_returns_config(self):
"""只有end_time也返回."""
result = TrimConfig.from_dict({"end_time": 8})
assert result is not None
assert result.end_time == 8.0
def test_string_values(self):
"""字符串值能正确解析."""
result = TrimConfig.from_dict({"start_time": "2.5", "duration": "3"})
assert result is not None
assert result.start_time == 2.5
assert result.duration == 3.0
def test_none_values_treated_as_zero(self):
"""None值当作0处理."""
result = TrimConfig.from_dict({"start_time": None, "duration": 5})
assert result is not None
assert result.start_time == 0.0
assert result.duration == 5.0
def test_false_values_treated_as_zero(self):
"""0/false值当作0处理."""
result = TrimConfig.from_dict({"start_time": 0, "duration": 0})
assert result is None
def test_all_three_params(self):
"""三个参数都给了."""
result = TrimConfig.from_dict({"start_time": 1, "end_time": 6, "duration": 5})
assert result is not None
assert result.start_time == 1.0
assert result.end_time == 6.0
assert result.duration == 5.0
# ── TrimConfig.validate_and_resolve 测试 ────────────────────────────────────
class TestTrimConfigValidateAndResolve:
"""validate_and_resolve 三选二推导 + 边界钳制测试."""
# 基础三选二推导
def test_start_and_end(self):
cfg = TrimConfig.from_dict({"start_time": 1.0, "end_time": 5.0})
assert cfg is not None
assert cfg.start_time == 1.0
assert cfg.end_time == 5.0
"""start + end → 推导duration."""
cfg = TrimConfig(start_time=5, end_time=15)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.start_time == 5.0
assert result.end_time == 15.0
assert result.duration == 10.0
def test_end_only(self):
cfg = TrimConfig.from_dict({"end_time": 8.0})
assert cfg is not None
assert cfg.end_time == 8.0
def test_start_and_duration(self):
"""start + duration → 推导end."""
cfg = TrimConfig(start_time=3, duration=7)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.start_time == 3.0
assert result.duration == 7.0
assert result.end_time == 10.0
def test_string_values_coerced(self):
cfg = TrimConfig.from_dict({"start_time": "3.5", "duration": "2.0"})
assert cfg is not None
assert cfg.start_time == 3.5
assert cfg.duration == 2.0
def test_end_and_duration(self):
"""end + duration → 推导start."""
cfg = TrimConfig(end_time=20, duration=5)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.end_time == 20.0
assert result.duration == 5.0
assert result.start_time == 15.0
def test_falsy_values_treated_as_zero(self):
cfg = TrimConfig.from_dict({"start_time": None, "duration": None})
assert cfg is None
def test_end_minus_duration_negative(self):
"""end + duration 但算出start<0 → 钳制到0重新计算."""
cfg = TrimConfig(end_time=3, duration=10)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.start_time == 0.0
assert result.end_time == 3.0
assert result.duration == 3.0
def test_default_values(self):
def test_only_start_takes_to_end(self):
"""只有start → 取到素材末尾."""
cfg = TrimConfig(start_time=5)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.start_time == 5.0
assert result.end_time == 30.0
assert result.duration == 25.0
def test_only_end_takes_from_start(self):
"""只有end → 从开头取到end."""
cfg = TrimConfig(end_time=10)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.start_time == 0.0
assert result.end_time == 10.0
assert result.duration == 10.0
def test_only_duration(self):
"""只有duration → 从开头取duration."""
cfg = TrimConfig(duration=8)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.start_time == 0.0
assert result.end_time == 8.0
assert result.duration == 8.0
def test_all_zero_noop(self):
"""全0 → noop不裁剪."""
cfg = TrimConfig()
assert cfg.start_time == 0.0
assert cfg.end_time == 0.0
assert cfg.duration == 0.0
result = cfg.validate_and_resolve(asset_duration=30)
assert result.start_time == 0.0
assert result.duration == 0.0
assert result.is_noop
# 边界钳制
# ── validate_and_resolve 测试 ─────────────────────────────────────────────
def test_start_negative_clamped(self):
"""start为负 → 钳制到0."""
cfg = TrimConfig(start_time=-5, duration=10)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.start_time == 0.0
assert result.duration == 10.0
assert result.end_time == 10.0
def test_end_exceeds_asset_duration(self):
"""end超过素材时长 → 钳制."""
cfg = TrimConfig(start_time=5, end_time=50)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.end_time == 30.0
assert result.duration == 25.0
class TestValidateAndResolve:
def test_start_and_end_resolves_duration(self):
cfg = TrimConfig(start_time=2.0, end_time=7.0)
resolved = cfg.validate_and_resolve(100.0)
assert resolved.start_time == 2.0
assert resolved.end_time == 7.0
assert resolved.duration == 5.0
def test_start_exceeds_asset_duration(self):
"""start超过素材时长 → 移到末尾取最小片段."""
cfg = TrimConfig(start_time=40, duration=5)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.end_time == 30.0
assert result.start_time >= 0
assert result.duration >= 0
def test_start_and_duration_resolves_end(self):
cfg = TrimConfig(start_time=3.0, duration=10.0)
resolved = cfg.validate_and_resolve(100.0)
assert resolved.start_time == 3.0
assert resolved.duration == 10.0
assert resolved.end_time == 13.0
def test_start_equals_end_invalid(self):
"""start >= end → 无效(duration=0."""
cfg = TrimConfig(start_time=10, end_time=10)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.duration == 0.0
assert result.is_valid is False
def test_end_and_duration_resolves_start(self):
cfg = TrimConfig(end_time=15.0, duration=5.0)
resolved = cfg.validate_and_resolve(100.0)
assert resolved.end_time == 15.0
assert resolved.duration == 5.0
assert resolved.start_time == 10.0
def test_start_only_takes_to_end(self):
cfg = TrimConfig(start_time=5.0)
resolved = cfg.validate_and_resolve(30.0)
assert resolved.start_time == 5.0
assert resolved.end_time == 30.0
assert resolved.duration == 25.0
def test_end_only_takes_from_start(self):
cfg = TrimConfig(end_time=8.0)
resolved = cfg.validate_and_resolve(30.0)
assert resolved.start_time == 0.0
assert resolved.end_time == 8.0
assert resolved.duration == 8.0
def test_duration_only_from_zero(self):
cfg = TrimConfig(duration=10.0)
resolved = cfg.validate_and_resolve(30.0)
assert resolved.start_time == 0.0
assert resolved.duration == 10.0
assert resolved.end_time == 10.0
def test_negative_start_clamped(self):
cfg = TrimConfig(start_time=-5.0, duration=10.0)
resolved = cfg.validate_and_resolve(30.0)
assert resolved.start_time == 0.0
def test_end_exceeds_asset_clamped(self):
cfg = TrimConfig(start_time=5.0, duration=50.0)
resolved = cfg.validate_and_resolve(30.0)
assert resolved.end_time == 30.0
assert resolved.duration == 25.0
def test_start_exceeds_asset_clamped(self):
cfg = TrimConfig(start_time=50.0, duration=10.0)
resolved = cfg.validate_and_resolve(30.0)
assert resolved.start_time < 30.0
assert resolved.end_time == 30.0
def test_end_before_start_invalid(self):
cfg = TrimConfig(start_time=10.0, end_time=5.0)
resolved = cfg.validate_and_resolve(30.0)
assert resolved.duration == 0.0
assert resolved.is_valid is False
def test_start_greater_than_end(self):
"""start > end → 无效."""
cfg = TrimConfig(start_time=15, end_time=10)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.duration == 0.0
assert result.is_valid is False
def test_zero_asset_duration(self):
cfg = TrimConfig(start_time=1.0, duration=5.0)
resolved = cfg.validate_and_resolve(0.0)
assert resolved.is_noop
"""素材时长为0 → 返回noop."""
cfg = TrimConfig(start_time=5, duration=10)
result = cfg.validate_and_resolve(asset_duration=0)
assert result.start_time == 0.0
assert result.duration == 0.0
assert result.is_noop
def test_negative_asset_duration(self):
cfg = TrimConfig(start_time=1.0, duration=5.0)
resolved = cfg.validate_and_resolve(-1.0)
assert resolved.is_noop
"""素材时长为负 → 返回noop."""
cfg = TrimConfig(start_time=1, duration=2)
result = cfg.validate_and_resolve(asset_duration=-5)
assert result.is_noop
def test_end_and_duration_with_negative_start(self):
cfg = TrimConfig(end_time=3.0, duration=10.0)
resolved = cfg.validate_and_resolve(30.0)
assert resolved.start_time == 0.0
assert resolved.end_time == 3.0
assert resolved.duration == 3.0
# duration 边界
def test_all_three_params_uses_start_duration(self):
cfg = TrimConfig(start_time=2.0, end_time=8.0, duration=3.0)
resolved = cfg.validate_and_resolve(30.0)
# 有 start + end 时应该用 start+end 推导 duration
assert resolved.start_time == 2.0
assert resolved.end_time == 8.0
assert resolved.duration == 6.0
def test_duration_preserved_exactly(self):
"""精确时长保持."""
cfg = TrimConfig(start_time=1.234, duration=2.567)
result = cfg.validate_and_resolve(asset_duration=10)
assert abs(result.duration - 2.567) < 0.001
assert abs(result.start_time - 1.234) < 0.001
def test_empty_config_returns_noop(self):
cfg = TrimConfig()
resolved = cfg.validate_and_resolve(30.0)
assert resolved.is_noop
def test_duration_never_negative(self):
"""duration永远不为负."""
cfg = TrimConfig(start_time=10, end_time=5)
result = cfg.validate_and_resolve(asset_duration=30)
assert result.duration >= 0
# ── is_valid / is_noop / trim_from_start 测试 ─────────────────────────────
# ── TrimConfig 属性测试 ─────────────────────────────────────────────────────
class TestProperties:
def test_is_valid_true_for_normal(self):
cfg = TrimConfig(start_time=0, end_time=0, duration=5.0)
class TestTrimConfigProperties:
"""TrimConfig 属性测试."""
def test_is_valid_valid_trim(self):
"""有效裁剪."""
cfg = TrimConfig(start_time=0, end_time=0, duration=5)
assert cfg.is_valid is True
def test_is_valid_false_for_zero(self):
cfg = TrimConfig(duration=0.0)
def test_is_valid_zero_duration(self):
"""duration=0无效."""
cfg = TrimConfig(duration=0)
assert cfg.is_valid is False
def test_is_valid_false_for_very_small(self):
cfg = TrimConfig(duration=0.01)
assert cfg.is_valid is False
def test_is_valid_true_at_boundary(self):
def test_is_valid_min_threshold(self):
"""刚好等于最小阈值也算有效."""
cfg = TrimConfig(duration=MIN_TRIM_DURATION)
assert cfg.is_valid is True
def test_is_noop_true_for_default(self):
cfg = TrimConfig()
def test_is_valid_below_min(self):
"""低于最小阈值无效."""
cfg = TrimConfig(duration=MIN_TRIM_DURATION / 2)
assert cfg.is_valid is False
def test_is_noop_true(self):
"""从0开始且duration=0是noop."""
cfg = TrimConfig(start_time=0, duration=0)
assert cfg.is_noop is True
def test_is_noop_false_with_start(self):
cfg = TrimConfig(start_time=1.0)
def test_is_noop_false_has_start(self):
"""有start不是noop."""
cfg = TrimConfig(start_time=5, duration=0)
assert cfg.is_noop is False
def test_is_noop_false_with_duration(self):
cfg = TrimConfig(duration=1.0)
def test_is_noop_false_has_duration(self):
"""有duration不是noop."""
cfg = TrimConfig(start_time=0, duration=1)
assert cfg.is_noop is False
def test_trim_from_start_true(self):
cfg = TrimConfig(start_time=0.0, duration=5.0)
"""start=0是从开头裁剪."""
cfg = TrimConfig(start_time=0, duration=5)
assert cfg.trim_from_start is True
def test_trim_from_start_false(self):
cfg = TrimConfig(start_time=2.0, duration=5.0)
"""start>0不是从开头裁剪."""
cfg = TrimConfig(start_time=2, duration=5)
assert cfg.trim_from_start is False
def test_trim_from_start_negative_treated_as_zero(self):
"""start<0也认为从开头."""
cfg = TrimConfig(start_time=-1, duration=5)
assert cfg.trim_from_start is True
# ── TrimSegment 测试 ───────────────────────────────────────────────────────
# ── TrimSegment 测试 ─────────────────────────────────────────────────────────
class TestTrimSegment:
def test_from_dict_basic(self):
seg = TrimSegment.from_dict({"segment_id": "s1", "start_time": 1.0, "duration": 3.0})
assert seg.segment_id == "s1"
assert seg.trim.start_time == 1.0
assert seg.trim.duration == 3.0
assert seg.order == 0
"""TrimSegment 测试."""
def test_from_dict_with_order(self):
seg = TrimSegment.from_dict({"segment_id": "s2", "start_time": 0, "end_time": 5.0, "order": 2})
def test_from_dict_basic(self):
"""基础构造."""
data = {"segment_id": "seg1", "start_time": 1, "end_time": 5, "order": 2}
seg = TrimSegment.from_dict(data)
assert seg.segment_id == "seg1"
assert seg.order == 2
assert seg.trim.start_time == 1.0
assert seg.trim.end_time == 5.0
def test_from_dict_default_order(self):
seg = TrimSegment.from_dict({"start_time": 1.0}, default_order=5)
"""缺order使用默认值."""
data = {"segment_id": "s1", "start_time": 0, "duration": 3}
seg = TrimSegment.from_dict(data, default_order=5)
assert seg.order == 5
def test_from_dict_default_segment_id(self):
seg = TrimSegment.from_dict({"start_time": 1.0}, default_order=3)
def test_from_dict_missing_segment_id(self):
"""缺segment_id用默认名."""
data = {"start_time": 0, "duration": 2}
seg = TrimSegment.from_dict(data, default_order=3)
assert seg.segment_id == "seg_3"
def test_from_dict_duration(self):
"""duration正确传递."""
data = {"segment_id": "s1", "duration": 10}
seg = TrimSegment.from_dict(data)
assert seg.trim.duration == 10.0
# ── build_video_trim_filter 测试 ───────────────────────────────────────────
# ── build_video_trim_filter 测试 ────────────────────────────────────────────
class TestBuildVideoTrimFilter:
def test_noop_returns_setpts(self):
"""build_video_trim_filter 视频滤镜构建测试."""
def test_noop_filter(self):
"""noop时只有setpts."""
cfg = TrimConfig()
result = build_video_trim_filter("[0:v]", cfg, "[v]")
assert "setpts=PTS-STARTPTS" in result
result = build_video_trim_filter("[0:v]", cfg, "[vout]")
assert "trim=" not in result
assert "[0:v]" in result
assert "[v]" in result
def test_with_start_and_duration(self):
cfg = TrimConfig(start_time=5.0, end_time=10.0, duration=5.0)
result = build_video_trim_filter("[0:v]", cfg, "[out]")
assert "trim=" in result
assert "start=5.000" in result
assert "duration=5.000" in result
assert "setpts=PTS-STARTPTS" in result
assert result.startswith("[0:v]")
assert result.endswith("[vout]")
def test_contains_input_and_output_labels(self):
cfg = TrimConfig(start_time=1.0, duration=2.0)
result = build_video_trim_filter("[in_v]", cfg, "[out_v]")
assert "[in_v]" in result
assert "[out_v]" in result
def test_start_and_duration(self):
"""start + duration 完整滤镜."""
cfg = TrimConfig(start_time=10, end_time=15, duration=5)
result = build_video_trim_filter("[0:v]", cfg, "[v0]")
assert "trim=start=10.000:duration=5.000" in result
assert "setpts=PTS-STARTPTS" in result
assert result.startswith("[0:v]")
assert result.endswith("[v0]")
def test_duration_only(self):
cfg = TrimConfig(duration=3.5)
def test_only_start(self):
"""只有start(取到末尾的情况resolve后也有duration."""
cfg = TrimConfig(start_time=5, end_time=30, duration=25)
result = build_video_trim_filter("[1:v]", cfg, "[v1]")
assert "start=5.000" in result
assert "duration=25.000" in result
def test_only_duration_from_start(self):
"""从开头裁剪duration."""
cfg = TrimConfig(start_time=0, end_time=3, duration=3)
result = build_video_trim_filter("[0:v]", cfg, "[out]")
assert "trim=duration=3.000" in result or "trim=start=0" in result
# start=0 不写,只有duration
assert "start=0" not in result
def test_preserves_input_output_labels(self):
"""保持输入输出标签."""
cfg = TrimConfig(start_time=1, duration=2)
result = build_video_trim_filter("[in_label]", cfg, "[out_label]")
assert result.startswith("[in_label]")
assert result.endswith("[out_label]")
def test_three_decimal_precision(self):
"""三位小数精度."""
cfg = TrimConfig(start_time=1.234, duration=2.678)
result = build_video_trim_filter("[0:v]", cfg, "[v]")
assert "duration=3.500" in result
assert "start=" not in result
assert "start=1.234" in result
assert "duration=2.678" in result
# ── build_audio_trim_filter 测试 ───────────────────────────────────────────
# ── build_audio_trim_filter 测试 ───────────────────────────────────────────
class TestBuildAudioTrimFilter:
def test_noop_returns_asetpts(self):
"""build_audio_trim_filter 音频滤镜构建测试."""
def test_noop_filter(self):
"""noop时只有asetpts."""
cfg = TrimConfig()
result = build_audio_trim_filter("[0:a]", cfg, "[a]")
assert "asetpts=PTS-STARTPTS" in result
result = build_audio_trim_filter("[0:a]", cfg, "[aout]")
assert "atrim=" not in result
assert "asetpts=PTS-STARTPTS" in result
assert result.startswith("[0:a]")
assert result.endswith("[aout]")
def test_with_start_and_duration(self):
cfg = TrimConfig(start_time=2.0, end_time=7.0, duration=5.0)
result = build_audio_trim_filter("[0:a]", cfg, "[out]")
assert "atrim=" in result
assert "start=2.000" in result
assert "duration=5.000" in result
def test_start_and_duration(self):
"""start + duration 完整滤镜."""
cfg = TrimConfig(start_time=5, duration=3)
result = build_audio_trim_filter("[0:a]", cfg, "[a0]")
assert "atrim=start=5.000:duration=3.000" in result
assert "asetpts=PTS-STARTPTS" in result
def test_contains_input_and_output_labels(self):
cfg = TrimConfig(start_time=1.0, duration=2.0)
result = build_audio_trim_filter("[in_a]", cfg, "[out_a]")
assert "[in_a]" in result
assert "[out_a]" in result
def test_only_duration(self):
"""只有durationstart=0时不写start参数)."""
cfg = TrimConfig(start_time=0, duration=4)
result = build_audio_trim_filter("[0:a]", cfg, "[a0]")
assert "atrim=duration=4.000" in result
def test_uses_atrim_not_trim(self):
"""用atrim不是trim."""
cfg = TrimConfig(start_time=1, duration=2)
result = build_audio_trim_filter("[0:a]", cfg, "[a]")
assert "atrim=" in result
assert ",trim=" not in result
# ── resolve_segments 测试 ──────────────────────────────────────────────────
# ── resolve_segments 测试 ────────────────────────────────────────────────────
class TestResolveSegments:
def test_empty_list_returns_empty(self):
result = resolve_segments([], 30.0)
"""resolve_segments 多段裁剪解析测试."""
def test_empty_list(self):
"""空列表返回空."""
result = resolve_segments([], asset_duration=30)
assert result == []
def test_single_segment(self):
segs = [TrimSegment(segment_id="s1", trim=TrimConfig(start_time=1.0, duration=5.0), order=0)]
result = resolve_segments(segs, 30.0)
"""单段解析."""
seg = TrimSegment(
segment_id="s1",
trim=TrimConfig(start_time=0, duration=5),
order=0,
)
result = resolve_segments([seg], asset_duration=30)
assert len(result) == 1
assert result[0].segment_id == "s1"
assert result[0].trim.duration == 5.0
def test_invalid_segment_filters_out(self):
def test_multiple_segments_sorted(self):
"""多段按order排序."""
segs = [
TrimSegment(segment_id="good", trim=TrimConfig(start_time=0, duration=5.0), order=0),
TrimSegment(
segment_id="bad",
trim=TrimConfig(start_time=5.0, end_time=5.0), # end == start → duration 0
order=1,
),
TrimSegment(segment_id="s1", trim=TrimConfig(duration=2), order=2),
TrimSegment(segment_id="s2", trim=TrimConfig(duration=3), order=0),
TrimSegment(segment_id="s3", trim=TrimConfig(duration=1), order=1),
]
result = resolve_segments(segs, 30.0)
assert len(result) == 1
assert result[0].segment_id == "good"
result = resolve_segments(segs, asset_duration=30)
assert len(result) == 3
assert result[0].segment_id == "s2"
assert result[1].segment_id == "s3"
assert result[2].segment_id == "s1"
def test_sorted_by_order(self):
def test_filter_invalid_segments(self):
"""过滤无效段."""
segs = [
TrimSegment(segment_id="s2", trim=TrimConfig(start_time=5.0, duration=3.0), order=2),
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=0, duration=3.0), order=1),
TrimSegment(segment_id="s0", trim=TrimConfig(start_time=10.0, duration=3.0), order=0),
TrimSegment(segment_id="valid", trim=TrimConfig(duration=5), order=0),
TrimSegment(segment_id="invalid", trim=TrimConfig(duration=0), order=1),
]
result = resolve_segments(segs, 30.0)
assert [s.segment_id for s in result] == ["s0", "s1", "s2"]
result = resolve_segments(segs, asset_duration=30)
assert len(result) == 1
assert result[0].segment_id == "valid"
def test_negative_order_uses_index(self):
"""order为负时使用索引."""
segs = [
TrimSegment(segment_id="s0", trim=TrimConfig(duration=3.0), order=-1),
TrimSegment(segment_id="s1", trim=TrimConfig(duration=2), order=-1),
TrimSegment(segment_id="s2", trim=TrimConfig(duration=3), order=-1),
]
result = resolve_segments(segs, 30.0)
result = resolve_segments(segs, asset_duration=30)
assert len(result) == 2
# order用各自的index值(0, 1)
def test_resolves_with_asset_duration(self):
"""用素材时长做边界钳制."""
seg = TrimSegment(
segment_id="s1",
trim=TrimConfig(start_time=0, duration=50), # 超过素材时长
order=0,
)
result = resolve_segments([seg], asset_duration=30)
assert len(result) == 1
assert result[0].order == 0
assert result[0].trim.end_time == 30.0
assert result[0].trim.duration == 30.0
# ── parse_segments_from_config 测试 ────────────────────────────────────────
# ── parse_segments_from_config 测试 ────────────────────────────────────────
class TestParseSegmentsFromConfig:
def test_none_returns_empty(self):
"""parse_segments_from_config 测试."""
def test_none_config(self):
"""None返回空."""
assert parse_segments_from_config(None) == []
def test_empty_dict_returns_empty(self):
def test_empty_config(self):
"""空dict返回空."""
assert parse_segments_from_config({}) == []
def test_trim_segments_list(self):
"""多段配置解析."""
config = {
"trim_segments": [
{"segment_id": "s1", "start_time": 0, "duration": 3.0, "order": 0},
{"segment_id": "s2", "start_time": 5.0, "duration": 2.0, "order": 1},
{"segment_id": "s1", "start_time": 0, "duration": 3, "order": 0},
{"segment_id": "s2", "start_time": 5, "duration": 4, "order": 1},
]
}
result = parse_segments_from_config(config)
assert len(result) == 2
assert result[0].segment_id == "s1"
assert result[0].trim.duration == 3.0
assert result[1].segment_id == "s2"
assert result[1].trim.start_time == 5.0
def test_trim_segments_skips_non_dict(self):
config = {"trim_segments": [{"segment_id": "s1", "duration": 3.0}, "invalid", None]}
result = parse_segments_from_config(config)
assert len(result) == 1
def test_trim_segments_empty_list(self):
"""空segments列表 + 无单段 → 空."""
config = {"trim_segments": []}
assert parse_segments_from_config(config) == []
def test_single_trim_compat(self):
config = {"trim_start": 1.0, "trim_duration": 5.0}
def test_trim_segments_not_list(self):
"""segments不是list → 回退到单段(如果有)."""
config = {"trim_segments": "not_a_list"}
assert parse_segments_from_config(config) == []
def test_single_trim_start(self):
"""单段:trim_start."""
config = {"trim_start": 2, "trim_duration": 5}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].segment_id == "main"
assert result[0].trim.start_time == 1.0
assert result[0].trim.start_time == 2.0
assert result[0].trim.duration == 5.0
def test_no_trim_fields_returns_empty(self):
config = {"other_field": "value"}
assert parse_segments_from_config(config) == []
def test_single_trim_end(self):
"""单段:trim_end."""
config = {"trim_end": 10}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].trim.end_time == 10.0
def test_segments_take_priority_over_single(self):
"""多段配置优先于单段."""
config = {
"trim_segments": [{"segment_id": "s1", "start_time": 0, "duration": 2}],
"trim_start": 5,
"trim_duration": 3,
}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].segment_id == "s1" # 多段优先
def test_segments_filter_non_dict(self):
"""过滤非dict元素."""
config = {
"trim_segments": [
{"segment_id": "s1", "duration": 2},
"not_a_dict",
None,
123,
]
}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].segment_id == "s1"
# ── extract_trim_from_clip_config 测试 ────────────────────────────────────
# ── extract_trim_from_clip_config 测试 ──────────────────────────────────────
class TestExtractTrimFromClipConfig:
def test_none_returns_none(self):
"""extract_trim_from_clip_config 测试."""
def test_none_config(self):
"""None返回None."""
assert extract_trim_from_clip_config(None) is None
def test_empty_dict_returns_none(self):
def test_empty_config(self):
"""空dict返回None."""
assert extract_trim_from_clip_config({}) is None
def test_trim_subdict(self):
config = {"trim": {"start_time": 2.0, "duration": 5.0}}
cfg = extract_trim_from_clip_config(config)
assert cfg is not None
assert cfg.start_time == 2.0
assert cfg.duration == 5.0
def test_flat_trim_fields(self):
config = {"trim_start": 1.0, "trim_end": 6.0}
cfg = extract_trim_from_clip_config(config)
assert cfg is not None
assert cfg.start_time == 1.0
assert cfg.end_time == 6.0
"""trim子字典提取."""
config = {"trim": {"start_time": 2, "duration": 5}}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.start_time == 2.0
assert result.duration == 5.0
def test_trim_subdict_empty(self):
"""trim子字典为空 → None."""
config = {"trim": {}}
assert extract_trim_from_clip_config(config) is None
def test_no_trim_fields(self):
config = {"foo": "bar"}
assert extract_trim_from_clip_config(config) is None
def test_flat_trim_fields(self):
"""扁平trim_字段."""
config = {"trim_start": 1, "trim_end": 6}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.start_time == 1.0
assert result.end_time == 6.0
def test_flat_trim_duration_only(self):
config = {"trim_duration": 10.0}
cfg = extract_trim_from_clip_config(config)
assert cfg is not None
assert cfg.duration == 10.0
def test_flat_trim_duration(self):
"""扁平trim_duration."""
config = {"trim_duration": 10}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.duration == 10.0
def test_trim_subdict_priority(self):
"""trim子字典优先于扁平字段."""
config = {
"trim": {"start_time": 1, "duration": 2},
"trim_start": 10,
"trim_duration": 20,
}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.start_time == 1.0
assert result.duration == 2.0
def test_trim_not_dict_ignored(self):
"""trim不是dict时忽略(回退到扁平字段)."""
config = {"trim": "not_a_dict", "trim_duration": 5}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.duration == 5.0
def test_no_trim_fields(self):
"""无裁剪字段返回None."""
config = {"other_field": "value", "font_size": 12}
assert extract_trim_from_clip_config(config) is None
+353 -119
View File
@@ -1,153 +1,387 @@
"""TtsConfig 配音配置模型单."""
"""TTS 配音配置模型单元测试."""
from __future__ import annotations
import pytest
from packages.domain.tts_config import TtsConfig
# ── 默认值测试 ────────────────────────────────────────────────────────────────
class TestTtsConfigDefaults:
def test_default_values(self):
config = TtsConfig()
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
assert config.pitch == 0.0
assert config.volume == 0.8
assert config.text == ""
assert config.align_mode == "full"
assert config.overlap_mode == "replace"
"""TtsConfig 默认值测试."""
def test_default_enabled_false(self):
"""默认禁用配音."""
cfg = TtsConfig()
assert cfg.enabled is False
def test_default_voice_id_empty(self):
"""默认空音色ID."""
cfg = TtsConfig()
assert cfg.voice_id == ""
def test_default_speed(self):
"""默认语速1.0."""
cfg = TtsConfig()
assert cfg.speed == 1.0
def test_default_pitch(self):
"""默认语调0."""
cfg = TtsConfig()
assert cfg.pitch == 0.0
def test_default_volume(self):
"""默认音量0.8."""
cfg = TtsConfig()
assert cfg.volume == 0.8
def test_default_text_empty(self):
"""默认空文本."""
cfg = TtsConfig()
assert cfg.text == ""
def test_default_align_mode(self):
"""默认整段配音对齐."""
cfg = TtsConfig()
assert cfg.align_mode == "full"
def test_default_overlap_mode(self):
"""默认替换原音."""
cfg = TtsConfig()
assert cfg.overlap_mode == "replace"
class TestTtsConfigParse:
def test_parse_none(self):
config = TtsConfig.parse(None)
assert config.enabled is False
assert isinstance(config, TtsConfig)
# ── parse - 基础场景测试 ─────────────────────────────────────────────────────
def test_parse_empty_dict(self):
config = TtsConfig.parse({})
assert config.enabled is False
def test_parse_not_dict(self):
config = TtsConfig.parse("not a dict")
assert config.enabled is False
class TestTtsConfigParseBasic:
"""TtsConfig.parse 基础场景测试."""
def test_parse_enabled_false_returns_disabled(self):
# 即使传了其他参数,enabled=False 就直接返回禁用
config = TtsConfig.parse({"enabled": False, "voice_id": "v1", "speed": 1.5})
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
def test_none_data(self):
"""None输入返回默认配置(禁用)."""
cfg = TtsConfig.parse(None)
assert cfg.enabled is False
def test_parse_enabled_true_with_all_fields(self):
config = TtsConfig.parse(
def test_empty_dict(self):
"""空dict返回默认配置."""
cfg = TtsConfig.parse({})
assert cfg.enabled is False
def test_not_dict_returns_default(self):
"""非dict输入返回默认."""
cfg = TtsConfig.parse("not_a_dict")
assert cfg.enabled is False
assert cfg.speed == 1.0
def test_disabled_returns_fast(self):
"""enabled为False时直接返回disabled配置."""
cfg = TtsConfig.parse({"enabled": False, "voice_id": "v1", "speed": 1.5})
assert cfg.enabled is False
# 其他字段为默认值
assert cfg.voice_id == ""
assert cfg.speed == 1.0
def test_enabled_basic(self):
"""启用配音基础配置."""
cfg = TtsConfig.parse({"enabled": True, "voice_id": "voice_001"})
assert cfg.enabled is True
assert cfg.voice_id == "voice_001"
def test_full_config(self):
"""完整配置解析."""
cfg = TtsConfig.parse(
{
"enabled": True,
"voice_id": "female_warm",
"speed": 1.5,
"pitch": 2.0,
"voice_id": "v_test",
"speed": 1.2,
"pitch": 2.5,
"volume": 0.9,
"text": "你好世界",
"text": "大家好",
"align_mode": "subtitle",
"overlap_mode": "mix",
}
)
assert config.enabled is True
assert config.voice_id == "female_warm"
assert config.speed == 1.5
assert config.pitch == 2.0
assert config.volume == 0.9
assert config.text == "你好世界"
assert config.align_mode == "subtitle"
assert config.overlap_mode == "mix"
def test_parse_enabled_not_bool(self):
config = TtsConfig.parse({"enabled": "true", "voice_id": "v1"})
assert config.enabled is False # 非 bool 值视为 False
def test_parse_voice_id_not_string(self):
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
assert config.voice_id == ""
def test_parse_speed_not_number(self):
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
assert config.speed == 1.0
def test_parse_pitch_not_number(self):
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
assert config.pitch == 0.0
def test_parse_volume_not_number(self):
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
assert config.volume == 0.8
def test_parse_text_not_string(self):
config = TtsConfig.parse({"enabled": True, "text": 12345})
assert config.text == ""
def test_parse_invalid_align_mode(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
assert config.align_mode == "full"
def test_parse_invalid_overlap_mode(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
assert config.overlap_mode == "replace"
assert cfg.enabled is True
assert cfg.voice_id == "v_test"
assert cfg.speed == 1.2
assert cfg.pitch == 2.5
assert cfg.volume == 0.9
assert cfg.text == "大家好"
assert cfg.align_mode == "subtitle"
assert cfg.overlap_mode == "mix"
class TestTtsConfigClamp:
def test_speed_below_min(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
assert config.speed == 0.5
# ── parse - 类型校验测试 ─────────────────────────────────────────────────────
def test_speed_above_max(self):
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
assert config.speed == 2.0
def test_speed_within_range(self):
config = TtsConfig.parse({"enabled": True, "speed": 1.2})
assert config.speed == 1.2
class TestTtsConfigParseTypeChecks:
"""TtsConfig.parse 类型校验测试."""
def test_speed_boundary_values(self):
config_low = TtsConfig.parse({"enabled": True, "speed": 0.5})
assert config_low.speed == 0.5
config_high = TtsConfig.parse({"enabled": True, "speed": 2.0})
assert config_high.speed == 2.0
def test_enabled_not_bool(self):
"""enabled不是bool时视为False."""
cfg = TtsConfig.parse({"enabled": "true", "voice_id": "v1"})
assert cfg.enabled is False
def test_pitch_below_min(self):
config = TtsConfig.parse({"enabled": True, "pitch": -20})
assert config.pitch == -12
def test_enabled_int_treated_as_non_bool(self):
"""enabled为整数时视为非boolPython里1是True但isinstance(1, bool)是True?)."""
# Python里bool是int的子类,isinstance(True, int)为True
# 反过来 isinstance(1, bool) 为 False,所以1会被当作无效值
cfg = TtsConfig.parse({"enabled": 1, "voice_id": "v1"})
assert cfg.enabled is False
def test_pitch_above_max(self):
config = TtsConfig.parse({"enabled": True, "pitch": 20})
assert config.pitch == 12
def test_voice_id_not_string(self):
"""voice_id不是字符串时回退到空."""
cfg = TtsConfig.parse({"enabled": True, "voice_id": 123})
assert cfg.voice_id == ""
def test_pitch_within_range(self):
config = TtsConfig.parse({"enabled": True, "pitch": -3.5})
assert config.pitch == -3.5
def test_speed_not_number(self):
"""speed不是数字时回退到1.0."""
cfg = TtsConfig.parse({"enabled": True, "speed": "fast"})
assert cfg.speed == 1.0
def test_volume_below_min(self):
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
assert config.volume == 0.0
def test_speed_int_accepted(self):
"""整数speed也接受."""
cfg = TtsConfig.parse({"enabled": True, "speed": 2})
assert cfg.speed == 2.0
def test_volume_above_max(self):
config = TtsConfig.parse({"enabled": True, "volume": 2.0})
assert config.volume == 1.0
def test_pitch_not_number(self):
"""pitch不是数字时回退到0."""
cfg = TtsConfig.parse({"enabled": True, "pitch": "high"})
assert cfg.pitch == 0.0
def test_volume_within_range(self):
config = TtsConfig.parse({"enabled": True, "volume": 0.5})
assert config.volume == 0.5
def test_pitch_int_accepted(self):
"""整数pitch也接受."""
cfg = TtsConfig.parse({"enabled": True, "pitch": 5})
assert cfg.pitch == 5.0
def test_int_speed_converted_to_float(self):
config = TtsConfig.parse({"enabled": True, "speed": 1})
assert isinstance(config.speed, float)
assert config.speed == 1.0
def test_volume_not_number(self):
"""volume不是数字时回退到0.8."""
cfg = TtsConfig.parse({"enabled": True, "volume": "loud"})
assert cfg.volume == 0.8
def test_int_pitch_converted_to_float(self):
config = TtsConfig.parse({"enabled": True, "pitch": 2})
assert isinstance(config.pitch, float)
assert config.pitch == 2.0
def test_volume_int_accepted(self):
"""整数volume也接受."""
cfg = TtsConfig.parse({"enabled": True, "volume": 1})
assert cfg.volume == 1.0
def test_int_volume_converted_to_float(self):
config = TtsConfig.parse({"enabled": True, "volume": 1})
assert isinstance(config.volume, float)
assert config.volume == 1.0
def test_text_not_string(self):
"""text不是字符串时回退到空."""
cfg = TtsConfig.parse({"enabled": True, "text": 12345})
assert cfg.text == ""
def test_text_empty_string(self):
"""空文本字符串是有效的."""
cfg = TtsConfig.parse({"enabled": True, "text": ""})
assert cfg.text == ""
# ── parse - 边界钳制测试 ─────────────────────────────────────────────────────
class TestTtsConfigParseClamping:
"""TtsConfig.parse 边界钳制测试."""
# speed 边界
def test_speed_below_min_clamped(self):
"""语速低于最小值钳制到0.5."""
cfg = TtsConfig.parse({"enabled": True, "speed": 0.1})
assert cfg.speed == 0.5
def test_speed_negative_clamped(self):
"""负语速钳制到0.5."""
cfg = TtsConfig.parse({"enabled": True, "speed": -1.0})
assert cfg.speed == 0.5
def test_speed_above_max_clamped(self):
"""语速高于最大值钳制到2.0."""
cfg = TtsConfig.parse({"enabled": True, "speed": 5.0})
assert cfg.speed == 2.0
def test_speed_at_min_ok(self):
"""刚好等于最小值正常."""
cfg = TtsConfig.parse({"enabled": True, "speed": 0.5})
assert cfg.speed == 0.5
def test_speed_at_max_ok(self):
"""刚好等于最大值正常."""
cfg = TtsConfig.parse({"enabled": True, "speed": 2.0})
assert cfg.speed == 2.0
def test_speed_normal_ok(self):
"""正常范围内语速保持不变."""
cfg = TtsConfig.parse({"enabled": True, "speed": 1.5})
assert cfg.speed == 1.5
# pitch 边界
def test_pitch_below_min_clamped(self):
"""语调低于最小值钳制到-12."""
cfg = TtsConfig.parse({"enabled": True, "pitch": -20})
assert cfg.pitch == -12
def test_pitch_above_max_clamped(self):
"""语调高于最大值钳制到12."""
cfg = TtsConfig.parse({"enabled": True, "pitch": 20})
assert cfg.pitch == 12
def test_pitch_at_min_ok(self):
"""刚好等于最小值正常."""
cfg = TtsConfig.parse({"enabled": True, "pitch": -12})
assert cfg.pitch == -12
def test_pitch_at_max_ok(self):
"""刚好等于最大值正常."""
cfg = TtsConfig.parse({"enabled": True, "pitch": 12})
assert cfg.pitch == 12
def test_pitch_normal_ok(self):
"""正常范围内语调保持不变."""
cfg = TtsConfig.parse({"enabled": True, "pitch": 3.5})
assert cfg.pitch == 3.5
# volume 边界
def test_volume_below_min_clamped(self):
"""音量低于最小值钳制到0."""
cfg = TtsConfig.parse({"enabled": True, "volume": -0.5})
assert cfg.volume == 0.0
def test_volume_above_max_clamped(self):
"""音量高于最大值钳制到1."""
cfg = TtsConfig.parse({"enabled": True, "volume": 2.0})
assert cfg.volume == 1.0
def test_volume_at_min_ok(self):
"""刚好等于最小值正常."""
cfg = TtsConfig.parse({"enabled": True, "volume": 0.0})
assert cfg.volume == 0.0
def test_volume_at_max_ok(self):
"""刚好等于最大值正常."""
cfg = TtsConfig.parse({"enabled": True, "volume": 1.0})
assert cfg.volume == 1.0
def test_volume_normal_ok(self):
"""正常范围内音量保持不变."""
cfg = TtsConfig.parse({"enabled": True, "volume": 0.6})
assert cfg.volume == 0.6
# ── parse - 枚举值校验测试 ───────────────────────────────────────────────────
class TestTtsConfigParseEnumValues:
"""TtsConfig.parse 枚举值校验测试."""
# align_mode
def test_align_mode_subtitle(self):
"""subtitle对齐模式有效."""
cfg = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
assert cfg.align_mode == "subtitle"
def test_align_mode_full(self):
"""full对齐模式有效."""
cfg = TtsConfig.parse({"enabled": True, "align_mode": "full"})
assert cfg.align_mode == "full"
def test_align_mode_invalid_fallback(self):
"""无效align_mode回退到full."""
cfg = TtsConfig.parse({"enabled": True, "align_mode": "word_by_word"})
assert cfg.align_mode == "full"
# overlap_mode
def test_overlap_mode_replace(self):
"""replace叠加模式有效."""
cfg = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
assert cfg.overlap_mode == "replace"
def test_overlap_mode_mix(self):
"""mix叠加模式有效."""
cfg = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
assert cfg.overlap_mode == "mix"
def test_overlap_mode_invalid_fallback(self):
"""无效overlap_mode回退到replace."""
cfg = TtsConfig.parse({"enabled": True, "overlap_mode": "duck"})
assert cfg.overlap_mode == "replace"
# ── _clamp 直接测试 ──────────────────────────────────────────────────────────
class TestTtsConfigClampDirect:
"""_clamp 方法直接调用测试."""
def test_clamp_speed_low(self):
"""手动构造低语速再clamp."""
cfg = TtsConfig(enabled=True, speed=0.1)
cfg._clamp()
assert cfg.speed == 0.5
def test_clamp_speed_high(self):
"""手动构造高速再clamp."""
cfg = TtsConfig(enabled=True, speed=10)
cfg._clamp()
assert cfg.speed == 2.0
def test_clamp_pitch_low(self):
"""手动构造低调再clamp."""
cfg = TtsConfig(enabled=True, pitch=-20)
cfg._clamp()
assert cfg.pitch == -12
def test_clamp_pitch_high(self):
"""手动构造高调再clamp."""
cfg = TtsConfig(enabled=True, pitch=20)
cfg._clamp()
assert cfg.pitch == 12
def test_clamp_volume_low(self):
"""手动构造低音量再clamp."""
cfg = TtsConfig(enabled=True, volume=-1)
cfg._clamp()
assert cfg.volume == 0.0
def test_clamp_volume_high(self):
"""手动构造高音量再clamp."""
cfg = TtsConfig(enabled=True, volume=2)
cfg._clamp()
assert cfg.volume == 1.0
def test_clamp_preserves_in_range(self):
"""范围内的值不变."""
cfg = TtsConfig(enabled=True, speed=1.2, pitch=3, volume=0.7)
cfg._clamp()
assert cfg.speed == 1.2
assert cfg.pitch == 3
assert cfg.volume == 0.7
# ── is_dataclass 验证 ───────────────────────────────────────────────────────
class TestTtsConfigStructure:
"""TtsConfig 结构验证."""
def test_is_dataclass(self):
"""是dataclass."""
from dataclasses import is_dataclass
assert is_dataclass(TtsConfig)
def test_equality(self):
"""相同配置相等."""
c1 = TtsConfig(enabled=True, voice_id="v1")
c2 = TtsConfig(enabled=True, voice_id="v1")
assert c1 == c2
def test_inequality(self):
"""不同配置不等."""
c1 = TtsConfig(enabled=True, voice_id="v1")
c2 = TtsConfig(enabled=True, voice_id="v2")
assert c1 != c2
Regular → Executable
+660 -294
View File
File diff suppressed because it is too large Load Diff
+387 -250
View File
@@ -1,4 +1,4 @@
"""video_concat 领域模型单测 — 纯逻辑,48个测试用例."""
"""video_concat 视频拼接领域模型单元测试."""
from __future__ import annotations
@@ -12,125 +12,192 @@ from packages.domain.video_concat import (
ConcatSegment,
)
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
"""常量测试."""
def test_max_concat_segments(self):
"""最大拼接段数."""
assert MAX_CONCAT_SEGMENTS == 50
def test_allowed_extensions_not_empty(self):
"""支持的视频格式不为空."""
assert len(ALLOWED_VIDEO_EXTENSIONS) > 0
def test_common_formats_supported(self):
"""常见格式都支持."""
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
assert ".avi" in ALLOWED_VIDEO_EXTENSIONS
assert ".mkv" in ALLOWED_VIDEO_EXTENSIONS
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
def test_demuxer_params_not_empty(self):
"""demuxer必需参数不为空."""
assert len(CONCAT_DEMUXER_REQUIRED_PARAMS) > 0
def test_demuxer_params_include_codec(self):
"""包含编解码相关参数."""
assert "codec_name" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "width" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "height" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "r_frame_rate" in CONCAT_DEMUXER_REQUIRED_PARAMS
# ── ConcatSegment 测试 ───────────────────────────────────────────────────────
class TestConcatSegmentBasics:
def test_default_values(self):
seg = ConcatSegment(video_path="test.mp4")
assert seg.video_path == "test.mp4"
class TestConcatSegment:
"""ConcatSegment 测试."""
def test_basic_creation(self):
"""基础创建."""
seg = ConcatSegment(video_path="/tmp/video.mp4")
assert seg.video_path == "/tmp/video.mp4"
assert seg.start_time == 0.0
assert seg.duration == 0.0
assert seg.has_audio is True
def test_full_params(self):
def test_full_creation(self):
"""完整字段创建."""
seg = ConcatSegment(
video_path="video.mp4",
video_path="/tmp/v.mov",
start_time=5.5,
duration=10.0,
has_audio=False,
)
assert seg.video_path == "video.mp4"
assert seg.video_path == "/tmp/v.mov"
assert seg.start_time == 5.5
assert seg.duration == 10.0
assert seg.has_audio is False
# is_valid 属性
class TestConcatSegmentFromDict:
def test_normal_dict(self):
seg = ConcatSegment.from_dict(
{
"video_path": "test.mp4",
"start_time": 2.0,
"duration": 5.0,
"has_audio": False,
}
)
assert seg.video_path == "test.mp4"
assert seg.start_time == 2.0
assert seg.duration == 5.0
assert seg.has_audio is False
def test_empty_dict(self):
seg = ConcatSegment.from_dict({})
assert seg.video_path == ""
assert seg.start_time == 0.0
assert seg.duration == 0.0
assert seg.has_audio is True
def test_none_input(self):
seg = ConcatSegment.from_dict(None)
assert seg.video_path == ""
assert seg.is_valid is False
def test_non_dict_input(self):
seg = ConcatSegment.from_dict("not a dict")
assert seg.video_path == ""
def test_start_time_negative_clamped(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": -5})
assert seg.start_time == 0.0
def test_duration_negative_clamped(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": -10})
assert seg.duration == 0.0
def test_start_time_invalid_string(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": "abc"})
assert seg.start_time == 0.0
def test_duration_invalid_string(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": "xyz"})
assert seg.duration == 0.0
def test_start_time_int_casted(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": 3})
assert seg.start_time == 3.0
def test_duration_int_casted(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": 7})
assert seg.duration == 7.0
def test_video_path_casted_to_string(self):
seg = ConcatSegment.from_dict({"video_path": 12345})
assert seg.video_path == "12345"
def test_has_audio_false(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "has_audio": False})
assert seg.has_audio is False
def test_has_audio_truthy_value(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "has_audio": 1})
assert seg.has_audio is True
class TestConcatSegmentProperties:
def test_is_valid_with_path(self):
seg = ConcatSegment(video_path="test.mp4")
"""有视频路径是有效的."""
seg = ConcatSegment(video_path="/tmp/v.mp4")
assert seg.is_valid is True
def test_is_valid_empty_path(self):
"""空路径无效."""
seg = ConcatSegment(video_path="")
assert seg.is_valid is False
def test_effective_duration_positive(self):
seg = ConcatSegment(video_path="a.mp4", duration=10.5)
"""正的effective_duration."""
seg = ConcatSegment(video_path="v.mp4", duration=10.5)
assert seg.effective_duration == 10.5
def test_effective_duration_zero(self):
seg = ConcatSegment(video_path="a.mp4", duration=0.0)
"""duration为0时effective_duration为0."""
seg = ConcatSegment(video_path="v.mp4", duration=0)
assert seg.effective_duration == 0.0
def test_effective_duration_negative(self):
seg = ConcatSegment(video_path="a.mp4", duration=-5.0)
"""负的duration被钳制到0."""
seg = ConcatSegment(video_path="v.mp4", duration=-5)
assert seg.effective_duration == 0.0
# ── ConcatConfig 测试 ────────────────────────────────────────────────────────
# ── ConcatSegment.from_dict 测试 ─────────────────────────────────────────────
class TestConcatConfigBasics:
def test_default_values(self):
class TestConcatSegmentFromDict:
"""ConcatSegment.from_dict 工厂方法测试."""
def test_none_input(self):
"""None输入返回默认片段(空路径)."""
seg = ConcatSegment.from_dict(None)
assert seg.video_path == ""
assert seg.is_valid is False
def test_empty_dict(self):
"""空dict返回默认."""
seg = ConcatSegment.from_dict({})
assert seg.video_path == ""
def test_not_dict_input(self):
"""非dict输入安全处理."""
seg = ConcatSegment.from_dict("not_a_dict")
assert seg.video_path == ""
def test_list_input(self):
"""list输入安全处理."""
seg = ConcatSegment.from_dict([1, 2, 3])
assert seg.video_path == ""
def test_with_video_path(self):
"""带视频路径."""
seg = ConcatSegment.from_dict({"video_path": "/tmp/v.mp4"})
assert seg.video_path == "/tmp/v.mp4"
assert seg.is_valid is True
def test_with_start_time(self):
"""带start_time."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": 3.5})
assert seg.start_time == 3.5
def test_start_time_negative_clamped(self):
"""负的start_time钳制到0."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": -5})
assert seg.start_time == 0.0
def test_start_time_invalid_string(self):
"""无效start_time字符串回退到0."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": "abc"})
assert seg.start_time == 0.0
def test_with_duration(self):
"""带duration."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": 8.5})
assert seg.duration == 8.5
def test_duration_negative_clamped(self):
"""负的duration钳制到0."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": -10})
assert seg.duration == 0.0
def test_duration_invalid_string(self):
"""无效duration回退到0."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": "xyz"})
assert seg.duration == 0.0
def test_has_audio_true(self):
"""has_audio为True."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "has_audio": True})
assert seg.has_audio is True
def test_has_audio_false(self):
"""has_audio为False."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "has_audio": False})
assert seg.has_audio is False
def test_has_audio_default_true(self):
"""has_audio默认True."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4"})
assert seg.has_audio is True
def test_string_start_time(self):
"""字符串形式的start_time."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": "2.5"})
assert seg.start_time == 2.5
def test_int_duration(self):
"""整数duration."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": 10})
assert seg.duration == 10.0
# ── ConcatConfig 基础测试 ────────────────────────────────────────────────────
class TestConcatConfig:
"""ConcatConfig 基础测试."""
def test_default_creation(self):
"""默认创建."""
cfg = ConcatConfig()
assert cfg.segments == []
assert cfg.output_width == 0
@@ -141,224 +208,294 @@ class TestConcatConfigBasics:
assert cfg.transition_duration == 0.3
def test_with_segments(self):
segs = [ConcatSegment(video_path="a.mp4")]
cfg = ConcatConfig(segments=segs)
assert len(cfg.segments) == 1
assert cfg.segments[0].video_path == "a.mp4"
class TestConcatConfigFromDict:
def test_none_config(self):
cfg = ConcatConfig.from_config_dict(None)
assert cfg.segments == []
assert cfg.output_width == 0
def test_empty_dict(self):
cfg = ConcatConfig.from_config_dict({})
assert cfg.segments == []
def test_non_dict_input(self):
cfg = ConcatConfig.from_config_dict("config")
assert cfg.segments == []
def test_with_valid_segments(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "a.mp4", "duration": 10},
{"video_path": "b.mp4", "duration": 20},
],
}
)
"""带片段创建."""
segs = [ConcatSegment(video_path="v1.mp4"), ConcatSegment(video_path="v2.mp4")]
cfg = ConcatConfig(segments=segs, output_width=1920, output_height=1080)
assert len(cfg.segments) == 2
assert cfg.segments[0].video_path == "a.mp4"
assert cfg.segments[1].video_path == "b.mp4"
def test_skips_empty_video_path(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "a.mp4"},
{"video_path": ""},
{"video_path": "b.mp4"},
],
}
)
assert len(cfg.segments) == 2
def test_skips_invalid_segment_dict(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "a.mp4"},
"not a dict",
{"video_path": "b.mp4"},
],
}
)
assert len(cfg.segments) == 2
def test_segments_not_a_list(self):
cfg = ConcatConfig.from_config_dict({"segments": "not a list"})
assert cfg.segments == []
def test_output_params(self):
cfg = ConcatConfig.from_config_dict(
{
"output_width": 1920,
"output_height": 1080,
"output_fps": 30.0,
"force_reencode": True,
}
)
assert cfg.output_width == 1920
assert cfg.output_height == 1080
assert cfg.output_fps == 30.0
assert cfg.force_reencode is True
def test_output_width_negative_clamped(self):
cfg = ConcatConfig.from_config_dict({"output_width": -100})
assert cfg.output_width == 0
# 属性测试
def test_output_height_invalid_string(self):
cfg = ConcatConfig.from_config_dict({"output_height": "abc"})
assert cfg.output_height == 0
def test_output_fps_invalid_string(self):
cfg = ConcatConfig.from_config_dict({"output_fps": "xyz"})
assert cfg.output_fps == 0.0
def test_transition_params(self):
cfg = ConcatConfig.from_config_dict(
{
"transition": "crossfade",
"transition_duration": 1.0,
}
)
assert cfg.transition == "crossfade"
assert cfg.transition_duration == 1.0
def test_transition_duration_minimum(self):
cfg = ConcatConfig.from_config_dict({"transition_duration": 0.01})
assert cfg.transition_duration == 0.1
def test_transition_duration_negative(self):
cfg = ConcatConfig.from_config_dict({"transition_duration": -1})
assert cfg.transition_duration == 0.1
def test_force_reencode_false_by_default(self):
cfg = ConcatConfig.from_config_dict({})
assert cfg.force_reencode is False
class TestConcatConfigProperties:
def test_has_effect_two_segments(self):
def test_has_effect_with_two_segments(self):
"""2个以上有效片段has_effect为True."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="a.mp4"),
ConcatSegment(video_path="b.mp4"),
ConcatSegment(video_path="v1.mp4"),
ConcatSegment(video_path="v2.mp4"),
]
)
assert cfg.has_effect is True
def test_has_effect_one_segment(self):
cfg = ConcatConfig(segments=[ConcatSegment(video_path="a.mp4")])
def test_has_effect_with_one_segment(self):
"""只有1个有效片段has_effect为False."""
cfg = ConcatConfig(segments=[ConcatSegment(video_path="v1.mp4")])
assert cfg.has_effect is False
def test_has_effect_empty(self):
def test_has_effect_with_no_segments(self):
"""空片段has_effect为False."""
cfg = ConcatConfig()
assert cfg.has_effect is False
def test_has_effect_skips_invalid(self):
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="a.mp4"),
ConcatSegment(video_path=""),
ConcatSegment(video_path="b.mp4"),
]
)
assert cfg.has_effect is True
def test_valid_segment_count(self):
"""有效片段计数."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="a.mp4"),
ConcatSegment(video_path=""),
ConcatSegment(video_path="b.mp4"),
ConcatSegment(video_path="v1.mp4"),
ConcatSegment(video_path=""), # 无效
ConcatSegment(video_path="v2.mp4"),
]
)
assert cfg.valid_segment_count == 2
def test_first_valid_segment(self):
def test_total_segments_alias(self):
"""total_segments是valid_segment_count的别名."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path=""),
ConcatSegment(video_path="first.mp4"),
ConcatSegment(video_path="v1.mp4"),
ConcatSegment(video_path="v2.mp4"),
]
)
assert cfg.total_segments == cfg.valid_segment_count
assert cfg.total_segments == 2
def test_first_valid_segment(self):
"""第一个有效片段."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path=""), # 无效
ConcatSegment(video_path="first_valid.mp4"),
ConcatSegment(video_path="second.mp4"),
]
)
assert cfg.first_valid_segment is not None
assert cfg.first_valid_segment.video_path == "first.mp4"
first = cfg.first_valid_segment
assert first is not None
assert first.video_path == "first_valid.mp4"
def test_first_valid_segment_none_when_all_empty(self):
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path=""),
ConcatSegment(video_path=""),
]
)
def test_first_valid_segment_none(self):
"""无有效片段时first_valid_segment为None."""
cfg = ConcatConfig(segments=[ConcatSegment(video_path="")])
assert cfg.first_valid_segment is None
def test_first_valid_segment_empty_list(self):
def test_first_valid_segment_empty(self):
"""空列表时为None."""
cfg = ConcatConfig()
assert cfg.first_valid_segment is None
def test_estimated_total_duration(self):
"""估算总时长."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="a.mp4", duration=10.0),
ConcatSegment(video_path="b.mp4", duration=20.0),
ConcatSegment(video_path="c.mp4", duration=0.0),
ConcatSegment(video_path="v1.mp4", duration=10.0),
ConcatSegment(video_path="v2.mp4", duration=5.5),
ConcatSegment(video_path="v3.mp4", duration=0), # 不计入
]
)
assert cfg.estimated_total_duration == 30.0
assert cfg.estimated_total_duration == pytest.approx(15.5)
def test_estimated_total_duration_empty(self):
"""空片段时长为0."""
cfg = ConcatConfig()
assert cfg.estimated_total_duration == 0.0
def test_estimated_total_duration_skips_invalid(self):
"""跳过无效片段."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="", duration=10.0),
ConcatSegment(video_path="a.mp4", duration=5.0),
ConcatSegment(video_path="", duration=100), # 无效,跳过
ConcatSegment(video_path="v1.mp4", duration=5.0),
]
)
assert cfg.estimated_total_duration == 5.0
class TestConcatConfigClampSegments:
def test_clamp_when_over_max(self):
segs = [ConcatSegment(video_path=f"s{i}.mp4") for i in range(100)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments(50)
assert len(cfg.segments) == 50
assert cfg.segments[0].video_path == "s0.mp4"
assert cfg.segments[-1].video_path == "s49.mp4"
# ── ConcatConfig.from_config_dict 测试 ───────────────────────────────────────
def test_no_clamp_when_under_max(self):
segs = [ConcatSegment(video_path=f"s{i}.mp4") for i in range(10)]
class TestConcatConfigFromConfigDict:
"""ConcatConfig.from_config_dict 工厂方法测试."""
def test_none_config(self):
"""None返回默认配置."""
cfg = ConcatConfig.from_config_dict(None)
assert cfg.segments == []
assert cfg.output_width == 0
def test_empty_config(self):
"""空dict返回默认."""
cfg = ConcatConfig.from_config_dict({})
assert cfg.segments == []
def test_not_dict(self):
"""非dict安全处理."""
cfg = ConcatConfig.from_config_dict("not_dict")
assert cfg.segments == []
def test_with_segments(self):
"""带片段列表."""
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "v1.mp4", "duration": 10},
{"video_path": "v2.mp4", "start_time": 2},
]
}
)
assert len(cfg.segments) == 2
assert cfg.segments[0].video_path == "v1.mp4"
assert cfg.segments[0].duration == 10.0
assert cfg.segments[1].start_time == 2.0
def test_segments_not_list(self):
"""segments不是list时忽略."""
cfg = ConcatConfig.from_config_dict({"segments": "not_a_list"})
assert cfg.segments == []
def test_skips_segments_without_path(self):
"""跳过没有video_path的片段."""
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "v1.mp4"},
{"duration": 5}, # 没有video_path
{"video_path": ""}, # 空path
]
}
)
assert len(cfg.segments) == 1
assert cfg.segments[0].video_path == "v1.mp4"
def test_skips_non_dict_segments(self):
"""跳过非dict片段."""
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "v1.mp4"},
"not_a_dict",
None,
123,
]
}
)
assert len(cfg.segments) == 1
def test_output_dimensions(self):
"""输出尺寸."""
cfg = ConcatConfig.from_config_dict(
{
"output_width": 1920,
"output_height": 1080,
}
)
assert cfg.output_width == 1920
assert cfg.output_height == 1080
def test_output_dimensions_negative_clamped(self):
"""负的尺寸钳制到0."""
cfg = ConcatConfig.from_config_dict(
{
"output_width": -100,
"output_height": -50,
}
)
assert cfg.output_width == 0
assert cfg.output_height == 0
def test_output_dimensions_invalid(self):
"""无效尺寸回退到0."""
cfg = ConcatConfig.from_config_dict(
{
"output_width": "abc",
"output_height": "xyz",
}
)
assert cfg.output_width == 0
assert cfg.output_height == 0
def test_output_fps(self):
"""输出帧率."""
cfg = ConcatConfig.from_config_dict({"output_fps": 30.0})
assert cfg.output_fps == 30.0
def test_output_fps_negative_clamped(self):
"""负帧率钳制到0."""
cfg = ConcatConfig.from_config_dict({"output_fps": -5})
assert cfg.output_fps == 0.0
def test_force_reencode(self):
"""强制重新编码."""
cfg = ConcatConfig.from_config_dict({"force_reencode": True})
assert cfg.force_reencode is True
def test_force_reencode_default_false(self):
"""默认不强制重编码."""
cfg = ConcatConfig.from_config_dict({})
assert cfg.force_reencode is False
def test_transition(self):
"""转场效果."""
cfg = ConcatConfig.from_config_dict({"transition": "crossfade"})
assert cfg.transition == "crossfade"
def test_transition_default_none(self):
"""默认转场none."""
cfg = ConcatConfig.from_config_dict({})
assert cfg.transition == "none"
def test_transition_duration(self):
"""转场时长."""
cfg = ConcatConfig.from_config_dict({"transition_duration": 1.0})
assert cfg.transition_duration == 1.0
def test_transition_duration_min(self):
"""转场时长最小值0.1."""
cfg = ConcatConfig.from_config_dict({"transition_duration": 0.01})
assert cfg.transition_duration == 0.1
def test_transition_duration_invalid(self):
"""无效转场时长回退到默认."""
cfg = ConcatConfig.from_config_dict({"transition_duration": "invalid"})
assert cfg.transition_duration == 0.3
# ── clamp_segments 测试 ──────────────────────────────────────────────────────
class TestClampSegments:
"""clamp_segments 截断测试."""
def test_under_limit_no_change(self):
"""低于上限时不截断."""
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(10)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments(50)
cfg.clamp_segments(max_segments=50)
assert len(cfg.segments) == 10
def test_default_max_constant(self):
assert MAX_CONCAT_SEGMENTS == 50
def test_over_limit_truncated(self):
"""超过上限时截断."""
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(100)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments(max_segments=30)
assert len(cfg.segments) == 30
assert cfg.segments[0].video_path == "v0.mp4"
assert cfg.segments[-1].video_path == "v29.mp4"
def test_default_max_uses_constant(self):
"""默认max_segments使用常量."""
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(100)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments() # 默认MAX_CONCAT_SEGMENTS
assert len(cfg.segments) == MAX_CONCAT_SEGMENTS
class TestConstants:
def test_allowed_extensions(self):
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
def test_empty_segments(self):
"""空列表不报错."""
cfg = ConcatConfig()
cfg.clamp_segments()
assert cfg.segments == []
def test_demuxer_params(self):
assert "codec_name" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "width" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "r_frame_rate" in CONCAT_DEMUXER_REQUIRED_PARAMS
def test_at_limit_stays(self):
"""刚好在上限时不变."""
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(50)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments(max_segments=50)
assert len(cfg.segments) == 50
+596 -252
View File
@@ -1,306 +1,650 @@
"""VoiceCloneProfile 领域模型单元测试 — Phase 3 CosyVoice 集成."""
"""VoiceCloneProfile 领域模型单元测试."""
from __future__ import annotations
from datetime import datetime, timezone
from time import sleep
import pytest
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
from packages.domain.voice_clone_profile import (
TERMINAL_STATUSES,
VoiceCloneProfile,
VoiceCloneStatus,
)
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
class TestVoiceCloneStatus:
"""VoiceCloneStatus 枚举测试."""
def test_status_values(self):
"""状态值正确."""
assert VoiceCloneStatus.PENDING.value == "pending"
assert VoiceCloneStatus.PROCESSING.value == "processing"
assert VoiceCloneStatus.READY.value == "ready"
assert VoiceCloneStatus.FAILED.value == "failed"
assert VoiceCloneStatus.DISABLED.value == "disabled"
def test_status_count(self):
"""共5种状态."""
assert len(VoiceCloneStatus) == 5
def test_is_str_enum(self):
"""是StrEnum,可与字符串直接比较."""
assert VoiceCloneStatus.PENDING == "pending"
assert VoiceCloneStatus.READY + "" == "ready"
def test_from_string(self):
"""从字符串构建枚举."""
assert VoiceCloneStatus("pending") == VoiceCloneStatus.PENDING
assert VoiceCloneStatus("ready") == VoiceCloneStatus.READY
def test_from_string_invalid(self):
"""无效字符串抛出ValueError."""
with pytest.raises(ValueError):
VoiceCloneStatus("invalid_status")
# ── 终态集合测试 ──────────────────────────────────────────────────────────────
class TestTerminalStatuses:
"""TERMINAL_STATUSES 终态集合测试."""
def test_ready_is_terminal(self):
"""ready是终态."""
assert VoiceCloneStatus.READY in TERMINAL_STATUSES
def test_failed_is_terminal(self):
"""failed是终态."""
assert VoiceCloneStatus.FAILED in TERMINAL_STATUSES
def test_disabled_is_terminal(self):
"""disabled是终态."""
assert VoiceCloneStatus.DISABLED in TERMINAL_STATUSES
def test_pending_not_terminal(self):
"""pending不是终态."""
assert VoiceCloneStatus.PENDING not in TERMINAL_STATUSES
def test_processing_not_terminal(self):
"""processing不是终态."""
assert VoiceCloneStatus.PROCESSING not in TERMINAL_STATUSES
def test_terminal_count(self):
"""共3个终态."""
assert len(TERMINAL_STATUSES) == 3
# ── 工厂方法测试 ──────────────────────────────────────────────────────────────
class TestVoiceCloneProfileCreate:
"""测试 VoiceCloneProfile.create() 工厂方法"""
"""VoiceCloneProfile.create 工厂方法测试."""
def test_create_success(self) -> None:
"""正常创建音色克隆档案。"""
profile = VoiceCloneProfile.create(
user_id="user_001",
name="我的音色",
description="用于配音的自定义音色",
source_audio_url="https://example.com/audio.wav",
voice_model="cosyvoice-v1",
language="zh-CN",
gender="female",
)
def test_create_basic(self):
"""基础创建."""
p = VoiceCloneProfile.create(user_id="user123", name="我的音色")
assert p.id # 自动生成
assert p.user_id == "user123"
assert p.name == "我的音色"
assert p.status == VoiceCloneStatus.PENDING
assert p.retry_count == 0
assert p.max_retries == 3
assert profile.id
assert profile.user_id == "user_001"
assert profile.name == "我的音色"
assert profile.description == "用于配音的自定义音色"
assert profile.status == VoiceCloneStatus.PENDING
assert profile.source_audio_url == "https://example.com/audio.wav"
assert profile.voice_model == "cosyvoice-v1"
assert profile.language == "zh-CN"
assert profile.gender == "female"
assert profile.retry_count == 0
assert profile.max_retries == 3
assert profile.created_at
assert profile.updated_at
def test_create_with_description(self):
"""带描述创建."""
p = VoiceCloneProfile.create(user_id="u1", name="T", description=" 测试描述 ")
assert p.description == "测试描述" # strip了
def test_create_minimal(self) -> None:
"""使用最小参数创建"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试音色")
def test_create_with_source_audio(self):
"""带源音频URL创建."""
url = "https://example.com/audio.wav"
p = VoiceCloneProfile.create(user_id="u1", name="T", source_audio_url=url)
assert p.source_audio_url == url
assert profile.user_id == "user_001"
assert profile.name == "测试音色"
assert profile.status == VoiceCloneStatus.PENDING
assert profile.description == ""
assert profile.source_audio_url == ""
assert profile.language == "zh-CN"
assert profile.gender == "unknown"
def test_create_with_language(self):
"""指定语言."""
p = VoiceCloneProfile.create(user_id="u1", name="T", language="en-US")
assert p.language == "en-US"
def test_create_empty_user_id_raises(self) -> None:
"""空 user_id 应抛出 ValueError。"""
with pytest.raises(ValueError, match="user_id 不能为空"):
VoiceCloneProfile.create(user_id="", name="测试")
def test_create_gender_normalized(self):
"""性别自动转小写."""
p = VoiceCloneProfile.create(user_id="u1", name="T", gender="Male")
assert p.gender == "male"
def test_create_whitespace_user_id_raises(self) -> None:
"""空白 user_id 应抛出 ValueError。"""
with pytest.raises(ValueError, match="user_id 不能为空"):
VoiceCloneProfile.create(user_id=" ", name="测试")
def test_create_custom_max_retries(self):
"""自定义最大重试次数."""
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=5)
assert p.max_retries == 5
def test_create_empty_name_raises(self) -> None:
"""空 name 应抛出 ValueError。"""
with pytest.raises(ValueError, match="name 不能为空"):
VoiceCloneProfile.create(user_id="user_001", name="")
def test_create_metadata(self):
"""元数据."""
meta = {"age": 30, "accent": "北方"}
p = VoiceCloneProfile.create(user_id="u1", name="T", metadata=meta)
assert p.metadata == meta
# 不是同一个对象引用(深拷贝?)
assert p.metadata is not meta or p.metadata == meta
def test_create_name_too_long_raises(self) -> None:
"""name 超过 100 字符应抛出 ValueError。"""
with pytest.raises(ValueError, match="name 长度不能超过 100 字符"):
VoiceCloneProfile.create(user_id="user_001", name="a" * 101)
def test_create_metadata_none(self):
"""metadata为None时默认为空dict."""
p = VoiceCloneProfile.create(user_id="u1", name="T", metadata=None)
assert p.metadata == {}
def test_create_strips_whitespace(self) -> None:
"""应去除首尾空白"""
profile = VoiceCloneProfile.create(
user_id=" user_001 ",
name=" 测试音色 ",
description=" 描述 ",
)
def test_create_user_id_stripped(self):
"""user_id去除空白."""
p = VoiceCloneProfile.create(user_id=" user123 ", name="T")
assert p.user_id == "user123"
assert profile.user_id == "user_001"
assert profile.name == "测试音色"
assert profile.description == "描述"
def test_create_name_stripped(self):
"""name去除空白."""
p = VoiceCloneProfile.create(user_id="u1", name=" 我的音色 ")
assert p.name == "我的音色"
def test_create_gender_normalized(self) -> None:
"""gender 应转换为小写。"""
profile = VoiceCloneProfile.create(
user_id="user_001",
name="测试",
gender="FEMALE",
)
def test_create_empty_user_id(self):
"""空user_id抛错."""
with pytest.raises(ValueError, match="user_id"):
VoiceCloneProfile.create(user_id="", name="T")
assert profile.gender == "female"
def test_create_whitespace_user_id(self):
"""纯空白user_id抛错."""
with pytest.raises(ValueError, match="user_id"):
VoiceCloneProfile.create(user_id=" ", name="T")
def test_create_empty_name(self):
"""空name抛错."""
with pytest.raises(ValueError, match="name"):
VoiceCloneProfile.create(user_id="u1", name="")
def test_create_whitespace_name(self):
"""纯空白name抛错."""
with pytest.raises(ValueError, match="name"):
VoiceCloneProfile.create(user_id="u1", name=" ")
def test_create_name_too_long(self):
"""name超过100字符抛错."""
long_name = "a" * 101
with pytest.raises(ValueError, match="100"):
VoiceCloneProfile.create(user_id="u1", name=long_name)
def test_create_name_exactly_100(self):
"""name恰好100字符正常."""
name = "a" * 100
p = VoiceCloneProfile.create(user_id="u1", name=name)
assert p.name == name
def test_create_has_created_at(self):
"""创建后有created_at时间戳."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
assert isinstance(p.created_at, datetime)
assert p.created_at.tzinfo is not None # 有时区
def test_create_has_updated_at(self):
"""创建后有updated_at时间戳."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
assert isinstance(p.updated_at, datetime)
def test_create_id_is_hex(self):
"""id是32位hex字符串(uuid4 hex)."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
assert len(p.id) == 32
# 全部是hex字符
int(p.id, 16) # 不抛错就是hex
class TestVoiceCloneProfileStatus:
"""测试状态相关属性和方法。"""
def test_initial_status_is_pending(self) -> None:
"""初始状态应为 PENDING。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
assert profile.status == VoiceCloneStatus.PENDING
def test_is_terminal_pending(self) -> None:
"""PENDING 不是终态。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
assert not profile.is_terminal
def test_is_terminal_ready(self) -> None:
"""READY 是终态。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
assert profile.is_terminal
def test_is_terminal_failed(self) -> None:
"""FAILED 是终态。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_failed("克隆失败")
assert profile.is_terminal
def test_is_retryable_not_failed(self) -> None:
"""非 FAILED 状态不可重试。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
assert not profile.is_retryable
def test_is_retryable_failed_under_limit(self) -> None:
"""FAILED 且未超过重试上限时可重试。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_failed("克隆失败")
assert profile.is_retryable
def test_is_retryable_failed_over_limit(self) -> None:
"""超过重试上限时不可重试。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试", max_retries=1)
profile.mark_processing()
profile.mark_failed("第一次失败")
profile.prepare_retry()
profile.mark_processing()
profile.mark_failed("第二次失败")
assert not profile.is_retryable
def test_is_ready_with_voice_id(self) -> None:
"""READY 且有 voice_id 时应返回 True。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
assert profile.is_ready
def test_is_ready_without_voice_id(self) -> None:
"""READY 但无 voice_id 时应返回 False。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.status = VoiceCloneStatus.READY
profile.voice_id = ""
assert not profile.is_ready
# ── 属性测试 ──────────────────────────────────────────────────────────────────
class TestVoiceCloneProfileTransitions:
"""测试状态转换。"""
class TestVoiceCloneProfileProperties:
"""VoiceCloneProfile 属性测试."""
def test_mark_processing(self) -> None:
"""PENDING → PROCESSING 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
assert profile.status == VoiceCloneStatus.PROCESSING
assert profile.error_message == ""
def test_is_terminal_pending(self):
"""pending不是终态."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
assert p.is_terminal is False
def test_mark_ready(self) -> None:
"""PROCESSING → READY 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
assert profile.status == VoiceCloneStatus.READY
assert profile.voice_id == "voice_001"
assert profile.error_message == ""
def test_is_terminal_processing(self):
"""processing不是终态."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
assert p.is_terminal is False
def test_mark_ready_empty_voice_id_raises(self) -> None:
"""mark_ready 空 voice_id 应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
with pytest.raises(ValueError, match="voice_id 不能为空"):
profile.mark_ready(voice_id="")
def test_is_terminal_ready(self):
"""ready是终态."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_ready("voice_001")
assert p.is_terminal is True
def test_mark_failed(self) -> None:
"""PROCESSING → FAILED 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_failed("API 调用失败")
assert profile.status == VoiceCloneStatus.FAILED
assert profile.error_message == "API 调用失败"
def test_is_terminal_failed(self):
"""failed是终态."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_failed("超时")
assert p.is_terminal is True
def test_mark_disabled_from_pending(self) -> None:
"""PENDING → DISABLED 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_disabled()
assert profile.status == VoiceCloneStatus.DISABLED
def test_is_terminal_disabled(self):
"""disabled是终态."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_disabled()
assert p.is_terminal is True
def test_mark_disabled_from_ready(self) -> None:
"""READY → DISABLED 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
profile.mark_disabled()
assert profile.status == VoiceCloneStatus.DISABLED
def test_is_retryable_failed_within_limit(self):
"""失败且未超过重试次数,可重试."""
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=3)
p.mark_processing()
p.mark_failed("error")
assert p.is_retryable is True
def test_invalid_transition_raises(self) -> None:
"""非法状态转换应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
def test_is_retryable_failed_at_limit(self):
"""失败但已达重试上限,不可重试."""
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=3)
p.mark_processing()
p.mark_failed("e1")
p.prepare_retry() # retry_count=1
p.mark_processing()
p.mark_failed("e2")
p.prepare_retry() # retry_count=2
p.mark_processing()
p.mark_failed("e3")
p.prepare_retry() # retry_count=3
p.mark_processing()
p.mark_failed("e4") # retry_count=3, max=3
assert p.is_retryable is False
def test_is_retryable_pending(self):
"""pending状态不可重试."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
assert p.is_retryable is False
def test_is_retryable_ready(self):
"""ready状态不可重试."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_ready("v1")
assert p.is_retryable is False
def test_is_retryable_processing(self):
"""processing状态不可重试."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
assert p.is_retryable is False
def test_is_ready_with_voice_id(self):
"""ready状态且有voice_idis_ready为True."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_ready("voice_001")
assert p.is_ready is True
def test_is_ready_no_voice_id(self):
"""ready状态但无voice_idis_ready为False."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.status = VoiceCloneStatus.READY # 手动设为ready但无voice_id
p.voice_id = ""
assert p.is_ready is False
def test_is_ready_pending(self):
"""pending状态is_ready为False."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
assert p.is_ready is False
def test_is_ready_failed(self):
"""failed状态is_ready为False."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_failed("err")
assert p.is_ready is False
# ── 状态转换测试 ──────────────────────────────────────────────────────────────
class TestTransitionTo:
"""transition_to 状态转换测试."""
def test_pending_to_processing(self):
"""pending → processing 合法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.transition_to(VoiceCloneStatus.PROCESSING)
assert p.status == VoiceCloneStatus.PROCESSING
def test_pending_to_failed(self):
"""pending → failed 合法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.transition_to(VoiceCloneStatus.FAILED)
assert p.status == VoiceCloneStatus.FAILED
def test_pending_to_disabled(self):
"""pending → disabled 合法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.transition_to(VoiceCloneStatus.DISABLED)
assert p.status == VoiceCloneStatus.DISABLED
def test_pending_to_ready_invalid(self):
"""pending → ready 非法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
with pytest.raises(ValueError, match="非法状态转换"):
profile.mark_ready(voice_id="voice_001") # PENDING → READY 非法
p.transition_to(VoiceCloneStatus.READY)
def test_invalid_status_string_raises(self) -> None:
"""无效状态字符串应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
def test_processing_to_ready(self):
"""processing → ready 合法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.transition_to(VoiceCloneStatus.READY)
assert p.status == VoiceCloneStatus.READY
def test_processing_to_failed(self):
"""processing → failed 合法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.transition_to(VoiceCloneStatus.FAILED)
assert p.status == VoiceCloneStatus.FAILED
def test_processing_to_disabled(self):
"""processing → disabled 合法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.transition_to(VoiceCloneStatus.DISABLED)
assert p.status == VoiceCloneStatus.DISABLED
def test_failed_to_pending(self):
"""failed → pending 合法(重试)."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_failed("err")
p.transition_to(VoiceCloneStatus.PENDING)
assert p.status == VoiceCloneStatus.PENDING
def test_failed_to_ready_invalid(self):
"""failed → ready 非法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_failed("err")
with pytest.raises(ValueError):
p.transition_to(VoiceCloneStatus.READY)
def test_ready_to_disabled(self):
"""ready → disabled 合法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_ready("v1")
p.transition_to(VoiceCloneStatus.DISABLED)
assert p.status == VoiceCloneStatus.DISABLED
def test_disabled_to_pending_invalid(self):
"""disabled → pending 非法."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_disabled()
with pytest.raises(ValueError):
p.transition_to(VoiceCloneStatus.PENDING)
def test_transition_with_string(self):
"""字符串输入的状态转换."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.transition_to("processing")
assert p.status == VoiceCloneStatus.PROCESSING
def test_transition_with_invalid_string(self):
"""无效字符串状态抛错."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
with pytest.raises(ValueError, match="无效状态"):
profile.transition_to("invalid_status")
p.transition_to("invalid")
def test_transition_to_with_string(self) -> None:
"""支持字符串形式的状态转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.transition_to("processing")
assert profile.status == VoiceCloneStatus.PROCESSING
def test_transition_updates_updated_at(self):
"""状态转换更新updated_at."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
old_updated = p.updated_at
sleep(0.01)
p.transition_to(VoiceCloneStatus.PROCESSING)
assert p.updated_at > old_updated
def test_transition_error_message_contains_statuses(self):
"""错误信息包含源状态和目标状态."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
with pytest.raises(ValueError) as exc_info:
p.transition_to(VoiceCloneStatus.READY)
msg = str(exc_info.value)
assert "pending" in msg
assert "ready" in msg
class TestVoiceCloneProfileRetry:
"""测试重试逻辑。"""
# ── 操作方法测试 ──────────────────────────────────────────────────────────────
def test_prepare_retry_success(self) -> None:
"""成功重试。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_failed("失败")
profile.prepare_retry()
assert profile.status == VoiceCloneStatus.PENDING
assert profile.retry_count == 1
assert profile.error_message == ""
assert profile.voice_id == ""
class TestMarkMethods:
"""mark_* 系列方法测试."""
def test_prepare_retry_not_failed_raises(self) -> None:
"""非 FAILED 状态重试应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
def test_mark_processing_clears_error(self):
"""mark_processing 清除错误信息."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.error_message = "previous error"
p.mark_processing()
assert p.error_message == ""
def test_mark_processing_from_pending(self):
"""从pending标记为processing."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
assert p.status == VoiceCloneStatus.PROCESSING
def test_mark_ready_with_voice_id(self):
"""mark_ready 正常标记."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_ready("voice_001")
assert p.status == VoiceCloneStatus.READY
assert p.voice_id == "voice_001"
def test_mark_ready_clears_error(self):
"""mark_ready 清除错误信息."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.error_message = "some error"
p.mark_ready("v1")
assert p.error_message == ""
def test_mark_ready_empty_voice_id(self):
"""空voice_id抛错."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
with pytest.raises(ValueError, match="voice_id"):
p.mark_ready("")
def test_mark_ready_whitespace_voice_id(self):
"""纯空白voice_id抛错."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
with pytest.raises(ValueError):
p.mark_ready(" ")
def test_mark_ready_strips_voice_id(self):
"""voice_id去除空白."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_ready(" voice_001 ")
assert p.voice_id == "voice_001"
def test_mark_failed_sets_error(self):
"""mark_failed 设置错误信息."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_failed("连接超时")
assert p.error_message == "连接超时"
def test_mark_failed_from_pending(self):
"""从pending直接失败."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_failed("验证失败")
assert p.status == VoiceCloneStatus.FAILED
assert p.error_message == "验证失败"
def test_mark_disabled_from_pending(self):
"""从pending禁用."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_disabled()
assert p.status == VoiceCloneStatus.DISABLED
def test_mark_disabled_from_ready(self):
"""从ready禁用."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_ready("v1")
p.mark_disabled()
assert p.status == VoiceCloneStatus.DISABLED
# ── 重试逻辑测试 ──────────────────────────────────────────────────────────────
class TestPrepareRetry:
"""prepare_retry 重试逻辑测试."""
def test_prepare_retry_basic(self):
"""基础重试成功."""
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=3)
p.mark_processing()
p.mark_failed("err")
p.prepare_retry()
assert p.status == VoiceCloneStatus.PENDING
assert p.retry_count == 1
def test_prepare_retry_clears_error(self):
"""重试清除错误信息."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_failed("big error")
p.prepare_retry()
assert p.error_message == ""
def test_prepare_retry_clears_voice_id(self):
"""重试清除voice_id."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.voice_id = "old_voice"
p.mark_processing()
p.mark_failed("err")
p.prepare_retry()
assert p.voice_id == ""
def test_prepare_retry_not_failed(self):
"""非failed状态不可重试."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
with pytest.raises(ValueError, match="不可重试"):
profile.prepare_retry()
def test_prepare_retry_over_limit_raises(self) -> None:
"""超过重试上限重试应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试", max_retries=1)
profile.mark_processing()
profile.mark_failed("第一次失败")
profile.prepare_retry()
profile.mark_processing()
profile.mark_failed("第二次失败")
p.prepare_retry()
def test_prepare_retry_exceeds_max(self):
"""超过最大重试次数不可重试."""
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=1)
p.mark_processing()
p.mark_failed("e1")
p.prepare_retry() # retry_count=1
p.mark_processing()
p.mark_failed("e2")
with pytest.raises(ValueError, match="不可重试"):
profile.prepare_retry()
p.prepare_retry()
def test_prepare_retry_error_has_details(self):
"""错误信息包含详细状态."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
with pytest.raises(ValueError) as exc_info:
p.prepare_retry()
msg = str(exc_info.value)
assert "pending" in msg
assert "retry_count" in msg
assert "max_retries" in msg
class TestVoiceCloneProfileToDict:
"""测试序列化。"""
# ── 序列化测试 ────────────────────────────────────────────────────────────────
def test_to_dict_contains_all_fields(self) -> None:
"""to_dict 应包含所有字段。"""
profile = VoiceCloneProfile.create(
user_id="user_001",
name="测试音色",
description="描述",
source_audio_url="https://example.com/audio.wav",
voice_model="cosyvoice-v1",
class TestToDict:
"""to_dict 序列化测试."""
def test_to_dict_keys(self):
"""序列化字典包含所有预期字段."""
p = VoiceCloneProfile.create(user_id="u1", name="测试音色")
d = p.to_dict()
expected_keys = {
"id",
"user_id",
"name",
"description",
"status",
"source_audio_url",
"voice_id",
"voice_model",
"language",
"gender",
"error_message",
"retry_count",
"max_retries",
"is_retryable",
"is_ready",
"metadata",
"created_at",
"updated_at",
}
assert set(d.keys()) == expected_keys
def test_to_dict_values(self):
"""序列化值正确."""
p = VoiceCloneProfile.create(
user_id="user123",
name="我的音色",
description="测试用",
language="zh-CN",
gender="female",
max_retries=5,
metadata={"key": "value"},
metadata={"source": "upload"},
)
d = p.to_dict()
assert d["user_id"] == "user123"
assert d["name"] == "我的音色"
assert d["description"] == "测试用"
assert d["status"] == "pending"
assert d["language"] == "zh-CN"
assert d["gender"] == "female"
assert d["retry_count"] == 0
assert d["max_retries"] == 5
assert d["is_retryable"] is False
assert d["is_ready"] is False
assert d["metadata"] == {"source": "upload"}
result = profile.to_dict()
def test_to_dict_ready_status(self):
"""ready状态下序列化正确."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_ready("voice_001")
d = p.to_dict()
assert d["status"] == "ready"
assert d["voice_id"] == "voice_001"
assert d["is_ready"] is True
assert d["is_retryable"] is False
assert result["id"] == profile.id
assert result["user_id"] == "user_001"
assert result["name"] == "测试音色"
assert result["description"] == "描述"
assert result["status"] == "pending"
assert result["source_audio_url"] == "https://example.com/audio.wav"
assert result["voice_model"] == "cosyvoice-v1"
assert result["language"] == "zh-CN"
assert result["gender"] == "female"
assert result["retry_count"] == 0
assert result["max_retries"] == 5
assert result["is_retryable"] is False
assert result["is_ready"] is False
assert result["metadata"] == {"key": "value"}
assert result["created_at"] is not None
assert result["updated_at"] is not None
def test_to_dict_failed_status(self):
"""failed状态下序列化正确."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
p.mark_failed("超时错误")
d = p.to_dict()
assert d["status"] == "failed"
assert d["error_message"] == "超时错误"
assert d["is_retryable"] is True
def test_to_dict_after_state_change(self) -> None:
"""状态变更后 to_dict 应反映最新状态。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
def test_to_dict_datetime_format(self):
"""时间字段是ISO格式字符串."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
d = p.to_dict()
# ISO格式可以被datetime解析
datetime.fromisoformat(d["created_at"])
datetime.fromisoformat(d["updated_at"])
result = profile.to_dict()
assert result["status"] == "ready"
assert result["voice_id"] == "voice_001"
assert result["is_ready"] is True
def test_to_dict_with_updated_at_after_transition(self):
"""状态转换后updated_at被序列化."""
p = VoiceCloneProfile.create(user_id="u1", name="T")
p.mark_processing()
d = p.to_dict()
assert d["updated_at"] is not None
assert isinstance(d["updated_at"], str)
+321 -56
View File
@@ -1,4 +1,6 @@
"""voice_presets 音色预设单."""
"""voice_presets 配音音色预设单元测试."""
from __future__ import annotations
import pytest
@@ -12,126 +14,389 @@ from packages.domain.voice_presets import (
list_voices,
)
# ── VoiceGender 枚举测试 ─────────────────────────────────────────────────────
class TestVoiceGender:
def test_values(self):
"""VoiceGender 枚举测试."""
def test_male(self):
"""男声."""
assert VoiceGender.MALE.value == "male"
def test_female(self):
"""女声."""
assert VoiceGender.FEMALE.value == "female"
def test_child(self):
"""童声."""
assert VoiceGender.CHILD.value == "child"
def test_is_str(self):
assert isinstance(VoiceGender.FEMALE, str)
def test_count(self):
"""共3种性别."""
assert len(VoiceGender) == 3
def test_is_str_enum(self):
"""可与字符串比较."""
assert VoiceGender.FEMALE == "female"
# ── VoiceStyle 枚举测试 ──────────────────────────────────────────────────────
class TestVoiceStyle:
def test_values(self):
"""VoiceStyle 枚举测试."""
def test_stable(self):
"""沉稳."""
assert VoiceStyle.STABLE.value == "stable"
def test_lively(self):
"""活泼."""
assert VoiceStyle.LIVELY.value == "lively"
def test_customer_service(self):
"""客服."""
assert VoiceStyle.CUSTOMER_SERVICE.value == "customer_service"
def test_narration(self):
"""旁白."""
assert VoiceStyle.NARRATION.value == "narration"
def test_news(self):
"""新闻."""
assert VoiceStyle.NEWS.value == "news"
def test_story(self):
"""故事."""
assert VoiceStyle.STORY.value == "story"
def test_count(self):
"""共6种风格."""
assert len(VoiceStyle) == 6
# ── VoicePreset 数据类测试 ───────────────────────────────────────────────────
class TestVoicePreset:
"""VoicePreset 数据类测试."""
def test_minimal_creation(self):
"""最小化创建(必填字段)."""
v = VoicePreset(voice_id="v1", name="测试音色")
assert v.voice_id == "v1"
assert v.name == "测试音色"
def test_default_values(self):
v = VoicePreset(voice_id="test", name="测试音色")
"""默认值正确."""
v = VoicePreset(voice_id="v1", name="T")
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.description == ""
assert v.provider == "mock"
assert v.provider_voice_id == ""
assert v.default_speed == 1.0
assert v.default_pitch == 0.0
assert v.sample_rate == 22050
assert v.language == "zh-CN"
def test_custom_values(self):
def test_full_creation(self):
"""完整字段创建."""
v = VoicePreset(
voice_id="male1",
name="男声",
voice_id="voice_full",
name="完整音色",
gender=VoiceGender.MALE,
style=VoiceStyle.STABLE,
style=VoiceStyle.NEWS,
description="测试描述",
provider="aliyun",
provider_voice_id="ali_001",
default_speed=0.9,
default_pitch=1.5,
sample_rate=44100,
language="en-US",
)
assert v.voice_id == "voice_full"
assert v.name == "完整音色"
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.STABLE
assert v.style == VoiceStyle.NEWS
assert v.description == "测试描述"
assert v.provider == "aliyun"
assert v.provider_voice_id == "ali_001"
assert v.default_speed == 0.9
assert v.default_pitch == 1.5
assert v.sample_rate == 44100
assert v.language == "en-US"
# ── MOCK_VOICES 列表测试 ─────────────────────────────────────────────────────
class TestMockVoices:
def test_mock_voices_not_empty(self):
"""MOCK_VOICES 预设列表测试."""
def test_not_empty(self):
"""列表不为空."""
assert len(MOCK_VOICES) > 0
def test_all_mock_voices_have_ids(self):
for v in MOCK_VOICES:
assert v.voice_id
assert v.name
assert v.provider == "mock"
def test_count(self):
"""共8个Mock音色."""
assert len(MOCK_VOICES) == 8
def test_unique_voice_ids(self):
def test_all_have_voice_id(self):
"""每个音色都有voice_id."""
for v in MOCK_VOICES:
assert v.voice_id, f"音色缺少voice_id: {v}"
def test_all_have_name(self):
"""每个音色都有name."""
for v in MOCK_VOICES:
assert v.name, f"{v.voice_id} 缺少name"
def test_voice_ids_unique(self):
"""voice_id唯一."""
ids = [v.voice_id for v in MOCK_VOICES]
assert len(ids) == len(set(ids))
assert len(ids) == len(set(ids)), f"重复ID: {[i for i in ids if ids.count(i) > 1]}"
def test_all_mock_provider(self):
"""都是mock供应商."""
for v in MOCK_VOICES:
assert v.provider == "mock", f"{v.voice_id} provider不是mock"
def test_all_chinese(self):
"""都是中文语言."""
for v in MOCK_VOICES:
assert v.language == "zh-CN"
def test_sample_rate_consistent(self):
"""采样率一致为22050."""
for v in MOCK_VOICES:
assert v.sample_rate == 22050
def test_gender_distribution(self):
"""性别分布:至少有男/女/童声."""
genders = {v.gender for v in MOCK_VOICES}
assert VoiceGender.MALE in genders
assert VoiceGender.FEMALE in genders
assert VoiceGender.CHILD in genders
def test_style_coverage(self):
"""覆盖多种风格."""
styles = {v.style for v in MOCK_VOICES}
assert len(styles) >= 4
def test_specific_voices_exist(self):
"""特定音色存在."""
ids = {v.voice_id for v in MOCK_VOICES}
assert "female_warm" in ids
assert "male_stable" in ids
assert "female_lively" in ids
assert "child_cute" in ids
def test_default_speed_positive(self):
"""语速都大于0."""
for v in MOCK_VOICES:
assert v.default_speed > 0, f"{v.voice_id}语速<=0"
def test_descriptions_not_empty(self):
"""都有描述."""
for v in MOCK_VOICES:
assert v.description, f"{v.voice_id}缺少描述"
assert len(v.description) > 5
# ── get_voice 测试 ───────────────────────────────────────────────────────────
class TestGetVoice:
"""get_voice 函数测试."""
def test_get_existing_voice(self):
"""获取存在的音色."""
v = get_voice("female_warm")
assert v is not None
assert v.voice_id == "female_warm"
assert v.name == "温暖女声"
def test_get_nonexistent_voice(self):
v = get_voice("nonexistent")
assert v is None
def test_get_male_stable(self):
"""获取沉稳男声."""
v = get_voice("male_stable")
assert v is not None
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.STABLE
def test_non_mock_provider_returns_none(self):
v = get_voice("female_warm", provider="aliyun")
assert v is None
def test_get_nonexistent_voice(self):
"""获取不存在的音色返回None."""
assert get_voice("nonexistent") is None
def test_get_empty_string(self):
"""空字符串返回None."""
assert get_voice("") is None
def test_mock_provider(self):
"""指定mock provider."""
v = get_voice("female_warm", provider="mock")
assert v is not None
assert v.provider == "mock"
def test_other_provider_returns_none(self):
"""其他provider返回None."""
assert get_voice("female_warm", provider="aliyun") is None
assert get_voice("female_warm", provider="xunfei") is None
def test_returns_same_instance(self):
"""返回同一个对象引用."""
v1 = get_voice("female_warm")
v2 = get_voice("female_warm")
assert v1 is v2
# ── list_voices 测试 ─────────────────────────────────────────────────────────
class TestListVoices:
def test_list_all(self):
voices = list_voices()
assert len(voices) == len(MOCK_VOICES)
"""list_voices 筛选函数测试."""
def test_filter_by_gender(self):
female_voices = list_voices(gender="female")
assert len(female_voices) > 0
assert all(v.gender == VoiceGender.FEMALE for v in female_voices)
def test_no_filter_returns_all(self):
"""无筛选返回全部."""
result = list_voices()
assert len(result) == len(MOCK_VOICES)
def test_filter_by_style(self):
story_voices = list_voices(style="story")
assert len(story_voices) > 0
assert all(v.style == VoiceStyle.STORY for v in story_voices)
def test_filter_by_gender_female(self):
"""按女性筛选."""
result = list_voices(gender="female")
assert len(result) > 0
assert all(v.gender == VoiceGender.FEMALE for v in result)
def test_filter_by_keyword_name(self):
voices = list_voices(keyword="女声")
assert len(voices) > 0
assert all("女声" in v.name for v in voices)
def test_filter_by_gender_male(self):
"""按男性筛选."""
result = list_voices(gender="male")
assert len(result) > 0
assert all(v.gender == VoiceGender.MALE for v in result)
def test_filter_by_keyword_description(self):
voices = list_voices(keyword="商务")
assert len(voices) > 0
assert any("商务" in v.description for v in voices)
def test_filter_by_gender_child(self):
"""按童声筛选."""
result = list_voices(gender="child")
assert len(result) >= 1
assert all(v.gender == VoiceGender.CHILD for v in result)
def test_filter_by_provider_non_mock(self):
voices = list_voices(provider="aliyun")
assert len(voices) == 0
def test_filter_by_invalid_gender(self):
"""无效性别返回空."""
result = list_voices(gender="alien")
assert result == []
def test_filter_multiple_conditions(self):
voices = list_voices(gender="female", style="narration")
assert len(voices) > 0
assert all(v.gender == VoiceGender.FEMALE for v in voices)
assert all(v.style == VoiceStyle.NARRATION for v in voices)
def test_filter_by_style_narration(self):
"""按旁白风格筛选."""
result = list_voices(style="narration")
assert len(result) > 0
assert all(v.style == VoiceStyle.NARRATION for v in result)
def test_filter_by_style_news(self):
"""按新闻风格筛选."""
result = list_voices(style="news")
assert len(result) >= 1
assert all(v.style == VoiceStyle.NEWS for v in result)
def test_filter_by_style_story(self):
"""按故事风格筛选."""
result = list_voices(style="story")
assert len(result) >= 1
assert all(v.style == VoiceStyle.STORY for v in result)
def test_filter_by_invalid_style(self):
"""无效风格返回空."""
result = list_voices(style="unknown")
assert result == []
def test_filter_by_provider_mock(self):
"""mock provider返回全部."""
result = list_voices(provider="mock")
assert len(result) == len(MOCK_VOICES)
def test_filter_by_provider_other(self):
"""其他provider返回空."""
result = list_voices(provider="aliyun")
assert result == []
def test_keyword_search_name(self):
"""关键词搜索name."""
result = list_voices(keyword="温暖")
assert len(result) >= 1
assert any(v.voice_id == "female_warm" for v in result)
def test_keyword_search_description(self):
"""关键词搜索description."""
result = list_voices(keyword="商务")
assert len(result) >= 1
assert any("商务" in v.description for v in result)
def test_keyword_search_voice_id(self):
"""关键词搜索voice_id."""
result = list_voices(keyword="male_stable")
assert len(result) >= 1
assert result[0].voice_id == "male_stable"
def test_keyword_case_insensitive(self):
voices1 = list_voices(keyword="FEMALE")
voices2 = list_voices(keyword="female")
assert len(voices1) == len(voices2)
"""关键词不区分大小写(英文)."""
r1 = list_voices(keyword="Female")
r2 = list_voices(keyword="female")
assert len(r1) == len(r2)
def test_keyword_no_match(self):
"""无匹配关键词返回空."""
result = list_voices(keyword="完全不存在的音色xyz")
assert result == []
def test_gender_and_style_combined(self):
"""性别+风格组合筛选."""
result = list_voices(gender="female", style="story")
assert len(result) >= 1
assert all(v.gender == VoiceGender.FEMALE for v in result)
assert all(v.style == VoiceStyle.STORY for v in result)
def test_returns_new_list(self):
"""返回新列表,修改不影响原数据."""
result = list_voices()
result.clear()
assert len(MOCK_VOICES) == 8
def test_empty_keyword_returns_all(self):
"""空关键词返回全部."""
result = list_voices(keyword="")
assert len(result) == len(MOCK_VOICES)
def test_none_keyword_returns_all(self):
"""None关键词返回全部."""
result = list_voices(keyword=None)
assert len(result) == len(MOCK_VOICES)
# ── get_default_voice 测试 ───────────────────────────────────────────────────
class TestGetDefaultVoice:
"""get_default_voice 测试."""
def test_default_voice_exists(self):
"""默认音色存在."""
v = get_default_voice()
assert v is not None
assert v == MOCK_VOICES[0]
assert isinstance(v, VoicePreset)
def test_default_is_first_mock(self):
"""默认音色是列表第一个."""
v = get_default_voice()
assert v is MOCK_VOICES[0]
def test_default_is_female_warm(self):
"""默认是温暖女声."""
v = get_default_voice()
assert v.voice_id == "female_warm"
assert v.name == "温暖女声"
assert v.gender == VoiceGender.FEMALE
def test_default_same_instance(self):
"""多次调用返回同一实例."""
v1 = get_default_voice()
v2 = get_default_voice()
assert v1 is v2