Compare commits

...

49 Commits

Author SHA1 Message Date
xiaoxia 8ce48c1b02 Merge branch 'develop' into refactor/tasks-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 39s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m7s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m55s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 57s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
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 1m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m37s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Successful in 2m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m23s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
CI/CD Pipeline / 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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 19s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
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
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 18s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:34:55 +08:00
CI Bot 7936be3339 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build 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 57s
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 / Validate - Type Check (mypy) (push) Successful in 2m10s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m18s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m16s
CI/CD Pipeline / Integration Tests (push) Successful in 3m51s
CI/CD Pipeline / Unit Tests (push) Successful in 9m12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m3s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-27 03:08:54 +00:00
xiaoxia d2c067e9bd test(wave121): 抽离xfade_builder领域模型 + 77单测
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m55s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m25s
CI/CD Pipeline / Unit Tests (push) Successful in 9m9s
CI/CD Pipeline / Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m44s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m13s
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 12m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 54s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m44s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-27 11:04:17 +08:00
CI Bot 925b365d6a style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build 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 57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m22s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m23s
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 / Build Staging Web Image (push) Successful in 1m56s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m21s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m22s
CI/CD Pipeline / Unit Tests (push) Successful in 7m2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-27 02:19:20 +00:00
xiaoxia 7c7f33fbd6 refactor(subscription): 拆分 UpgradeSubscription 页面,抽离 Hook + UI 组件 (#1020)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m58s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m33s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 44s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m1s
CI/CD Pipeline / Unit Tests (push) Successful in 6m19s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Frontend Lint (push) Failing after 40s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m35s
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 13m4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 58s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m22s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m55s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-27 10:09:26 +08:00
xiaoxia b891eeab43 refactor(templates): 拆分 MyTemplates 页面,抽离数据 Hook + 卡片组件 (#1019) 2026-07-27 10:09:26 +08:00
xiaoxia dfed224b7d test(wave120): 抽离ass_subtitle_builder领域模型 + 68单测 (#1016) 2026-07-27 10:09:14 +08:00
xiaoxia 9a849d319e test(wave119): 抽离noise_reduction_config领域模型 + 39单测 (#1014) 2026-07-27 10:09:14 +08:00
xiaoxia 07bbe7ee01 test(wave118): 抽离chroma_key_config领域模型 + 45单测 (#1013) 2026-07-27 10:09:14 +08:00
CI Bot af7088a549 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Blocked by required conditions
CI/CD Pipeline / Validate - Code Quality (push) Blocked by required conditions
CI/CD Pipeline / Validate - Type Check (mypy) (push) Blocked by required conditions
CI/CD Pipeline / Validate - Migration (alembic) (push) Blocked by required conditions
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) Blocked by required conditions
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Web Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
2026-07-27 01:19:35 +00:00
xiaoxia 7d75ee6586 refactor(editing-planner): 拆分 useEditPlanClips Hook,按职责拆为子模块 (#1007)
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 3m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m1s
CI/CD Pipeline / Unit Tests (push) Successful in 5m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI/CD Pipeline / Frontend Lint (push) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m8s
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 20m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m31s
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) Successful in 1m57s
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
2026-07-27 08:47:23 +08:00
xiaoxia 83bf454daf refactor(generate): 拆分 useStep4Title Hook,按职责拆为 AI生成/历史/模板 (#1004) 2026-07-27 08:47:23 +08:00
xiaoxia ea04bb8525 test(wave116): 抽离trim_config领域模型 + 64单测 (#1005) 2026-07-27 08:47:23 +08:00
xiaoxia d537dd2ed0 test(wave115): 抽离watermark_config领域模型 + 76单测 (#1001)
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
2026-07-27 08:44:53 +08:00
CI Bot 18590e22e5 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-27 00:03:46 +00:00
xiaoxia a96819ce22 test(wave114): 抽离color_grade_config领域模型 + 56单测 (#1000)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 29s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 58s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m33s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m11s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m12s
AI Code Review / AI Code Review (pull_request) Successful in 3m39s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m4s
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 / Frontend Unit Tests (pull_request) Successful in 35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m24s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 22s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 31s
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 / Production Browser E2E (pull_request) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
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 / 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-27 07:24:04 +08:00
xiaoxia b98acefe0f fix(ci): 修复daily-check/acr-cleanup docker兼容性 + 清理ruff历史遗留 + 修复Unit Tests问题 (#981)
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
fix(ci): 修复daily-check/acr-cleanup docker兼容性 + 清理ruff历史遗留 + 修复Unit Tests死循环和ffmpeg兼容性问题 (#981)
2026-07-27 07:23:54 +08:00
xiaoxia 2d67fe8631 refactor(test): 第109波 - sticker_config领域模型抽离 + 52单测 (#991)
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
2026-07-27 07:23:03 +08:00
xiaoxia dc500acbf2 refactor(test): 第108波 - ai_parsing纯逻辑模块抽离 + 44单测 (#986) 2026-07-27 07:23:03 +08:00
xiaoxia 2253b7d15a Merge branch 'develop' of https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas 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
2026-07-27 07:21:30 +08:00
CI Bot 9a25eb6642 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-26 23:21:17 +00:00
xiaoxia ad3dc06101 refactor(product-library): 深化拆分,抽离筛选栏/批量操作栏/空状态 (#993) 2026-07-27 07:21:16 +08:00
xiaoxia dc555bc8c1 refactor(editing-planner): 拆分 types.ts 为目录结构,按功能模块分文件 (#992) 2026-07-27 07:21:16 +08:00
xiaoxia dcab4180e5 refactor(editing-planner): 深化拆分,抽离全局配置与配音素材 Hook (#990) 2026-07-27 07:21:16 +08:00
xiaoxia 8d7e13ea73 refactor(editing-planner): split EditingDrawers into sub-components (#978) 2026-07-27 07:21:16 +08:00
xiaoxia 09a19f69c7 refactor(generate): split useStep5Voice into sub-modules (#975) 2026-07-27 07:21:16 +08:00
xiaoxia ac667e60c9 refactor(wave113): 抽离transition_config领域模型 + 49单测 (#999)
CI/CD Pipeline / Check if frontend-only change (push) Blocked by required conditions
CI/CD Pipeline / Validate - Code Quality (push) Blocked by required conditions
CI/CD Pipeline / Validate - Type Check (mypy) (push) Blocked by required conditions
CI/CD Pipeline / Validate - Migration (alembic) (push) Blocked by required conditions
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) Blocked by required conditions
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Web Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-27 07:18:00 +08:00
xiaoxia 3ec74bfc32 refactor(wave112): 抽离intro_outro_config领域模型 + 52单测 (#998) 2026-07-27 07:18:00 +08:00
xiaoxia c0af8e7c43 refactor(wave111): 抽离audio_track_config领域模型 + 67单测 (#997) 2026-07-27 07:18:00 +08:00
xiaoxia 1399912095 refactor(wave110): 抽离pip_config领域模型 + 64单测 (#995) 2026-07-27 07:18:00 +08:00
CI Bot 9f153eec54 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m9s
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 46s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m4s
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 / Validate - Code Quality (push) Failing after 2m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m21s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m22s
CI/CD Pipeline / Unit Tests (push) Failing after 6m18s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 16:28:12 +00:00
xiaoxia b392bb1b78 test(wave107): subtitle_style领域模型抽离 + 76单测 (#985)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m44s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m35s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m41s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m20s
CI/CD Pipeline / Unit Tests (push) Failing after 6m36s
CI/CD Pipeline / Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m1s
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 16m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m17s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 33s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-27 00:25:33 +08:00
xiaoxia b852603664 test(wave106): video_concat领域模型抽离 + 53单测 (#984) 2026-07-27 00:25:33 +08:00
xiaoxia bda2170b9d test(wave105): extract render_layer_utils domain module (#983) 2026-07-27 00:25:32 +08:00
xiaoxia bb0d01f080 test(wave104): extract template_clip_converter domain module (#982) 2026-07-27 00:25:32 +08:00
xiaoxia f6a458564f test(wave88): streaming_service+asset_types+asset_usage+title_usage + N单测 (#932) 2026-07-27 00:25:32 +08:00
CI Bot 48bf66c298 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m24s
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 32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 55s
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 / Validate - Migration (alembic) (push) Successful in 1m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m38s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 23s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m14s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 42m36s
2026-07-26 14:08:03 +00:00
xiaoxia 8e2c563b42 refactor(template-library): 三阶段重构 TemplateLibrary(716→107行, -85%)(#931)
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
2026-07-26 22:06:12 +08:00
xiaoxia b99437fd81 test(wave103): 抽取clip_operations纯逻辑模块 + 57个单测 (#977)
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 / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
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 / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
2026-07-26 22:04:30 +08:00
xiaoxia 81eff29e7b test(wave101): 抽取video_filter_builder纯逻辑模块 + 73个单测 (#972) 2026-07-26 22:04:30 +08:00
xiaoxia 02d60a02f2 test(wave100): 40 unit tests for 3 application layer modules (#964) 2026-07-26 22:04:30 +08:00
xiaoxia 5b0ba40ecd test(wave99): 35 unit tests for 8 small domain modules (#962) 2026-07-26 22:04:29 +08:00
xiaoxia 9b9dc243ed test(wave98): 31 unit tests for preset_bgm domain module (#961) 2026-07-26 22:04:19 +08:00
xiaoxia 6514b8c34d test(wave97): 53 unit tests for quota domain system (#960) 2026-07-26 22:04:19 +08:00
xiaoxia 4e8265ec5e test(wave96): 76 unit tests for Job domain model (#959) 2026-07-26 22:04:19 +08:00
xiaoxia 23de3906c2 test(wave95): 82 unit tests for domain entities (#958) 2026-07-26 22:04:19 +08:00
xiaoxia 50b05db03e test(wave94): extract media_validation domain module + 70 unit tests (#957) 2026-07-26 22:04:19 +08:00
CI Bot 7f3c462617 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 56s
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 / Validate - Code Quality (push) Failing after 1m30s
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 / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m51s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m3s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 1h12m17s
2026-07-26 10:47:55 +00:00
xiaoxia f31408baee refactor(api): 拆分 tasks.ts 为目录结构(types/tasks/index)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 28s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 33s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m11s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Successful in 1m38s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 29s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m25s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m45s
CI/CD Pipeline / 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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 20s
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 / Production Browser E2E (pull_request) Has been skipped
将 123 行的 tasks.ts 拆分为目录化结构:
- types.ts: 类型定义(TaskItem/TaskStatus/创建请求响应等)
- tasks.ts: 全部 5 个 API 函数
- index.ts: 统一入口 re-export,保持 @/api/tasks 路径向后兼容
2026-07-26 17:12:41 +08:00
146 changed files with 21014 additions and 7041 deletions
+5 -6
View File
@@ -36,12 +36,11 @@ jobs:
GITEA_REPO: xiaoxia/xiaoxia-saas GITEA_REPO: xiaoxia/xiaoxia-saas
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 shell: sh
env:
- name: Setup Python GITHUB_TOKEN: ${{ github.token }}
uses: actions/setup-python@v5 run: |
with: curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
python-version: '3.12'
# ====== Cron模式:获取staging运行中镜像作为白名单 ====== # ====== Cron模式:获取staging运行中镜像作为白名单 ======
- name: Get staging running images (whitelist) - name: Get staging running images (whitelist)
+10 -123
View File
@@ -1,4 +1,5 @@
name: Daily Health Check name: Daily Health Check
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
on: on:
schedule: schedule:
@@ -23,47 +24,9 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
set -eu curl -sH "Authorization: token $GITHUB_TOKEN" \
python3 - <<'PY' "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
import io, os, tarfile, time, urllib.request, urllib.error | bash
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Production health check & smoke test - name: Production health check & smoke test
id: smoke id: smoke
shell: sh shell: sh
@@ -132,47 +95,9 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
set -eu curl -sH "Authorization: token $GITHUB_TOKEN" \
python3 - <<'PY' "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
import io, os, tarfile, time, urllib.request, urllib.error | bash
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run API smoke test on staging - name: Run API smoke test on staging
id: smoke id: smoke
shell: sh shell: sh
@@ -284,47 +209,9 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
set -eu curl -sH "Authorization: token $GITHUB_TOKEN" \
python3 - <<'PY' "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
import io, os, tarfile, time, urllib.request, urllib.error | bash
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run Playwright E2E on staging - name: Run Playwright E2E on staging
id: e2e id: e2e
shell: sh shell: sh
+12 -199
View File
@@ -14,10 +14,12 @@ from __future__ import annotations
import json import json
import logging import logging
import math
import random
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
from packages.domain.ai_parsing import parse_semantic_match_response as _parse_semantic_match_base
from packages.domain.ai_parsing import parse_titles_from_response as _parse_titles_from_response
from packages.shared.ai_client import get_doubao_client from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -64,85 +66,9 @@ def _generate_titles_fallback(
style: str = "viral", style: str = "viral",
count: int = 5, count: int = 5,
) -> List[str]: ) -> List[str]:
"""本地降级:基于模板规则生成标题. """本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
"""
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"]) style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
examples = style_info["examples"] return _generate_titles_fallback_base(description, style_info, count)
# 从描述中提取关键词(取前几个词)
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
keyword = keywords[0] if keywords else "精彩内容"
# 基于模板生成
templates = [
f"{keyword}{examples[0][:10]}...",
f"{keyword}{examples[1]}",
f"关于{keyword},你不知道的3件事",
f"{keyword}入门指南,新手必看",
f"深度解析:{keyword}背后的秘密",
f"{keyword}怎么做?手把手教你",
f"干货分享 | {keyword}全攻略",
f"建议收藏:{keyword}实用技巧",
f"{keyword}避坑指南,别再踩雷了",
f"一分钟搞懂{keyword}",
]
random.shuffle(templates)
return templates[: min(count, len(templates))]
def _parse_titles_from_response(content: str) -> List[str]:
"""从模型返回中解析标题列表.
支持多种返回格式:
- JSON 数组: ["标题1", "标题2"]
- 编号列表: 1. 标题1 / 2. 标题2
- 换行分隔: 标题1\n标题2
- 带破折号: - 标题1
"""
if not content:
return []
# 尝试解析 JSON
try:
# 清理可能的 markdown 代码块标记
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
if isinstance(data, list):
return [str(item).strip() for item in data if str(item).strip()]
if isinstance(data, dict) and "titles" in data:
titles = data["titles"]
if isinstance(titles, list):
return [str(t).strip() for t in titles if str(t).strip()]
except (json.JSONDecodeError, ValueError):
pass
# 尝试按行解析
titles: List[str] = []
for line in content.strip().split("\n"):
line = line.strip()
if not line:
continue
# 去掉编号前缀 "1. " "1、" "1"
import re
line = re.sub(r"^[\d]+[\.、\)]\s*", "", line)
# 去掉破折号前缀 "- " "• "
line = re.sub(r"^[-•·]\s*", "", line)
# 去掉引号
line = line.strip('"').strip("'").strip("「」")
if line and len(line) < 100: # 过滤过长的行
titles.append(line)
return titles
def generate_smart_titles( def generate_smart_titles(
@@ -241,132 +167,19 @@ def _semantic_match_fallback(
description: str, description: str,
assets: List[Dict[str, Any]], assets: List[Dict[str, Any]],
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""本地降级:基于关键词的简单匹配. """本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
return _semantic_match_fallback_base(description, assets)
计算描述中的关键词与素材名称/标签/描述的重叠度,
作为匹配度评分。0-1分。
"""
import re
# 提取关键词(中文按2字以上片段,英文按单词)
desc = description.lower()
# 简单分词:提取2字以上的中文字符串和英文单词
keywords = set()
# 英文单词
for word in re.findall(r"[a-zA-Z]{3,}", desc):
keywords.add(word)
# 中文2-4字片段
for i in range(len(desc)):
for j in range(i + 2, min(i + 5, len(desc) + 1)):
fragment = desc[i:j]
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
keywords.add(fragment)
if not keywords:
# 没有关键词时给所有素材中等分数
for asset in assets:
asset["match_score"] = 0.5
asset["match_reason"] = "fallback_default"
return assets
results = []
for asset in assets:
# 组合素材的文本信息:名称 + 标签 + 描述
asset_text_parts = [
str(asset.get("name", "")).lower(),
" ".join(str(t) for t in asset.get("tags", [])).lower(),
str(asset.get("description", "")).lower(),
]
asset_text = " | ".join(asset_text_parts)
# 计算匹配度:命中关键词占比 + 稀有关键词加权
hit_count = 0
hit_keywords = []
for kw in keywords:
if kw in asset_text:
hit_count += 1
hit_keywords.append(kw)
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
# 名称命中加分(名称匹配更重要)
name = str(asset.get("name", "")).lower()
name_hits = sum(1 for kw in hit_keywords if kw in name)
name_bonus = min(0.2, name_hits * 0.05)
score = min(1.0, base_score * 0.8 + name_bonus)
score = round(score, 3)
results.append(
{
**asset,
"match_score": score,
"match_reason": "fallback_keyword",
}
)
# 按匹配度降序
results.sort(key=lambda x: x["match_score"], reverse=True)
return results
def _parse_semantic_match_response( def _parse_semantic_match_response(
content: str, content: str,
asset_ids: List[str], asset_ids: List[str],
) -> Optional[Dict[str, float]]: ) -> Optional[Dict[str, float]]:
"""从模型返回中解析素材匹配度. """从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
result = _parse_semantic_match_base(content, asset_ids)
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]} if result is None:
score 范围 0-1。
"""
if not content:
return None return None
return dict(result)
# 尝试解析 JSON
try:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
result: Dict[str, float] = {}
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
if isinstance(data, dict):
if "matches" in data and isinstance(data["matches"], list):
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
for item in data["matches"]:
if isinstance(item, dict):
aid = item.get("asset_id") or item.get("id")
score = item.get("score", 0)
if aid and isinstance(score, (int, float)):
result[str(aid)] = max(0.0, min(1.0, float(score)))
else:
for key, value in data.items():
if isinstance(value, (int, float)):
result[str(key)] = max(0.0, min(1.0, float(value)))
# 格式3: [{"asset_id": "...", "score": 0.8}]
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
aid = item.get("asset_id") or item.get("id")
score = item.get("score", 0)
if aid and isinstance(score, (int, float)):
result[str(aid)] = max(0.0, min(1.0, float(score)))
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
return result
except (json.JSONDecodeError, ValueError):
pass
return None
def semantic_match_assets( def semantic_match_assets(
+52 -63
View File
@@ -16,6 +16,11 @@ from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanRepository, SQLAlchemyEditPlanRepository,
SQLAlchemyGenerationTaskRepository, SQLAlchemyGenerationTaskRepository,
) )
from packages.domain.clip_operations import calculate_merge as _calc_merge
from packages.domain.clip_operations import calculate_shift_orders as _calc_shift_orders
from packages.domain.clip_operations import calculate_split as _calc_split
from packages.domain.clip_operations import validate_merge_clips as _validate_merge
from packages.domain.clip_operations import validate_split_time as _validate_split
from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
@@ -384,36 +389,45 @@ class EditPlanService:
clip = self.get_clip_or_raise(clip_id) clip = self.get_clip_or_raise(clip_id)
plan_id = clip.plan_id plan_id = clip.plan_id
if split_time <= 0 or split_time >= clip.duration: # 纯逻辑:校验 + 计算
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}") _validate_split(split_time, clip.duration)
split = _calc_split(
duration=clip.duration,
split_time=split_time,
start_time=clip.start_time,
)
self._auto_resume_editing(plan_id) self._auto_resume_editing(plan_id)
original_duration = clip.duration
left_duration = round(split_time, 3)
right_duration = round(original_duration - split_time, 3)
original_order = clip.order original_order = clip.order
# 更新左半部分(原片段) # 更新左半部分(原片段)
clip.duration = left_duration clip.duration = split.left_duration
left_clip = self._clip_repo.update(clip) left_clip = self._clip_repo.update(clip)
# 后面片段的 order 全部 +1(给右半部分腾位置) # 后面片段的 order 全部 +1(给右半部分腾位置)
all_clips = self._clip_repo.list_by_plan(plan_id) all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips: shifts = _calc_shift_orders(
if c.order > original_order and c.id != clip_id: all_clips,
c.order += 1 threshold_order=original_order,
self._clip_repo.update(c) shift=1,
excluded_ids={clip_id},
id_attr="id",
order_attr="order",
)
for c, new_order in shifts:
c.order = new_order
self._clip_repo.update(c)
# 创建右半部分新片段(继承原片段的大部分属性) # 创建右半部分新片段(继承原片段的大部分属性)
right_config = dict(clip.config) if clip.config else {} right_config = dict(clip.config) if clip.config else {}
# 素材裁剪信息 # 素材裁剪信息
if clip.asset_id: if clip.asset_id:
# 右半部分从 split_time 开始播放 # 右半部分从 split_time 开始播放
right_config["trim_start"] = left_duration right_config["trim_start"] = split.right_trim_start
# 左半部分在 split_time 处结束 # 左半部分在 split_time 处结束
left_config = dict(left_clip.config) if left_clip.config else {} left_config = dict(left_clip.config) if left_clip.config else {}
left_config["trim_end"] = right_duration left_config["trim_end"] = split.left_trim_end
left_clip.config = left_config left_clip.config = left_config
left_clip = self._clip_repo.update(left_clip) left_clip = self._clip_repo.update(left_clip)
@@ -424,8 +438,8 @@ class EditPlanService:
template_clip_config_id=clip.template_clip_config_id, template_clip_config_id=clip.template_clip_config_id,
asset_id=clip.asset_id, asset_id=clip.asset_id,
text_content=clip.text_content, text_content=clip.text_content,
start_time=clip.start_time + left_duration, start_time=split.right_start_time,
duration=right_duration, duration=split.right_duration,
transition_effect=clip.transition_effect, transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration, transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed, playback_speed=clip.playback_speed,
@@ -438,8 +452,8 @@ class EditPlanService:
clip_id, clip_id,
plan_id, plan_id,
split_time, split_time,
left_duration, split.left_duration,
right_duration, split.right_duration,
) )
return { return {
@@ -468,70 +482,45 @@ class EditPlanService:
clip = self.get_clip_or_raise(cid) clip = self.get_clip_or_raise(cid)
clips.append(clip) clips.append(clip)
# 校验:同一计划 # 纯逻辑:校验 + 计算
plan_id = clips[0].plan_id plan_id, first_order = _validate_merge(clips)
for c in clips[1:]: merge = _calc_merge(clips)
if c.plan_id != plan_id:
raise ValueError("只能合并同一计划下的片段")
# 按 order 排序
clips.sort(key=lambda c: c.order)
# 校验:order 连续
for i in range(1, len(clips)):
if clips[i].order != clips[i - 1].order + 1:
raise ValueError(f"片段不连续:order {clips[i-1].order}{clips[i].order}")
# 校验:类型一致
clip_type = clips[0].clip_type
for c in clips[1:]:
if c.clip_type != clip_type:
raise ValueError("只能合并相同类型的片段")
self._auto_resume_editing(plan_id) self._auto_resume_editing(plan_id)
# 计算合并后的属性
first_clip = clips[0]
total_duration = round(sum(c.duration for c in clips), 3)
first_order = first_clip.order
# 合并文案(用换行连接)
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
# 合并 config(后面的覆盖前面的)
merged_config: Dict[str, Any] = {}
for c in clips:
if c.config:
merged_config.update(c.config)
# 清理 trim 相关字段(合并后就是完整片段了)
merged_config.pop("trim_start", None)
merged_config.pop("trim_end", None)
# 更新第一个片段(保留它作为合并结果) # 更新第一个片段(保留它作为合并结果)
first_clip.duration = total_duration first_clip = sorted(clips, key=lambda c: c.order)[0]
first_clip.text_content = merged_text first_clip.duration = merge.total_duration
first_clip.config = merged_config first_clip.text_content = merge.merged_text
first_clip.config = merge.merged_config
# 转场保留第一个的(合并后的入点转场) # 转场保留第一个的(合并后的入点转场)
# playback_speed 取第一个的 # playback_speed 取第一个的
merged_clip = self._clip_repo.update(first_clip) merged_clip = self._clip_repo.update(first_clip)
# 删除其余片段 # 删除其余片段
for c in clips[1:]: rest_ids = [c.id for c in clips if c.id != merged_clip.id]
self._clip_repo.delete(c.id) for cid in rest_ids:
self._clip_repo.delete(cid)
# 后面的片段 order 前移 (len - 1) 位 # 后面的片段 order 前移 (len - 1) 位
shift = len(clips) - 1
all_clips = self._clip_repo.list_by_plan(plan_id) all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips: shifts = _calc_shift_orders(
if c.order > first_order and c.id != merged_clip.id: all_clips,
c.order -= shift threshold_order=first_order,
self._clip_repo.update(c) shift=-merge.shift_amount,
excluded_ids={merged_clip.id},
id_attr="id",
order_attr="order",
)
for c, new_order in shifts:
c.order = new_order
self._clip_repo.update(c)
logger.info( logger.info(
"合并片段: plan_id=%s count=%d total_duration=%.3fs", "合并片段: plan_id=%s count=%d total_duration=%.3fs",
plan_id, plan_id,
len(clips), len(clips),
total_duration, merge.total_duration,
) )
return merged_clip return merged_clip
+15 -161
View File
@@ -23,6 +23,13 @@ from packages.domain.template_clip_config import (
TemplateClipConfig, TemplateClipConfig,
TransitionEffect, TransitionEffect,
) )
from packages.domain.template_clip_converter import (
clip_configs_to_snapshots,
clips_to_template_clip_configs,
filter_plan_config_to_template,
snapshots_to_template_clip_configs,
validate_template_name,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -121,9 +128,7 @@ class EditTemplateService:
ValueError: 名称为空或重复 ValueError: 名称为空或重复
""" """
# 名称校验 # 名称校验
clean_name = name.strip() clean_name = validate_template_name(name)
if not clean_name:
raise ValueError("模板名称不能为空")
# 名称重复检查 # 名称重复检查
existing = self._template_repo.list_all(skip=0, limit=1000) existing = self._template_repo.list_all(skip=0, limit=1000)
@@ -471,12 +476,7 @@ class EditTemplateService:
raise ValueError(f"模板名称已存在: {clean_name}") raise ValueError(f"模板名称已存在: {clean_name}")
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段 # 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
plan_config = plan.config or {} template_config = filter_plan_config_to_template(plan.config)
template_config: dict[str, Any] = {}
for key, value in plan_config.items():
# 跳过明显的运行时/实例字段,保留风格/模式类配置
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
template_config[key] = value
template = EditTemplate.create( template = EditTemplate.create(
name=clean_name, name=clean_name,
@@ -497,40 +497,7 @@ class EditTemplateService:
# 5. 转换每个片段为模板片段配置 # 5. 转换每个片段为模板片段配置
created_configs: List[TemplateClipConfig] = [] created_configs: List[TemplateClipConfig] = []
for clip in clips: for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
transition = TransitionEffect(clip.transition_effect)
except ValueError:
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
clip_type = ClipType(clip.clip_type)
except ValueError:
clip_type = ClipType.MAIN
clip_config_obj = TemplateClipConfig.create(
template_id=created_template.id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(clip_config_obj) created = self._clip_config_repo.create(clip_config_obj)
created_configs.append(created) created_configs.append(created)
@@ -679,8 +646,6 @@ class EditTemplateService:
Raises: Raises:
ValueError: 模板/草稿不存在,或草稿不属于该模板 ValueError: 模板/草稿不存在,或草稿不属于该模板
""" """
from packages.domain.template_clip_config import TemplateClipConfig
# 1. 校验模板和草稿 # 1. 校验模板和草稿
template = self.get_template_or_raise(template_id) template = self.get_template_or_raise(template_id)
draft = self._plan_repo.get(draft_plan_id) draft = self._plan_repo.get(draft_plan_id)
@@ -700,39 +665,14 @@ class EditTemplateService:
editing_mode = config.get("editing_mode", "one_take") editing_mode = config.get("editing_mode", "one_take")
# 4. 提取模板配置(去掉草稿/运行时字段) # 4. 提取模板配置(去掉草稿/运行时字段)
draft_config = draft.config or {} template_config = filter_plan_config_to_template(draft.config)
template_config: dict[str, Any] = {}
skip_keys = {
"is_template_draft",
"asset_ids",
"source_edit_plan_id",
"generation_task_id",
}
for key, value in draft_config.items():
if key not in skip_keys:
template_config[key] = value
# 5. 事务更新 # 5. 事务更新
try: try:
# 5.0 先保存旧版快照(发布前的状态),用于回滚 # 5.0 先保存旧版快照(发布前的状态),用于回滚
old_version = template.version or 1 old_version = template.version or 1
old_clip_configs = self._clip_config_repo.list_by_template(template_id) old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [ old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion from packages.domain.template_version import EditTemplateVersion
@@ -759,46 +699,7 @@ class EditTemplateService:
# 创建新的片段配置 # 创建新的片段配置
created_configs: list[TemplateClipConfig] = [] created_configs: list[TemplateClipConfig] = []
for clip in draft_clips: for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import (
TransitionEffect,
)
transition = TransitionEffect(clip.transition_effect)
except (ValueError, ImportError):
transition = TransitionEffect.CUT # type: ignore
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip.clip_type)
except (ValueError, ImportError):
clip_type = ClipType.MAIN # type: ignore
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(config_obj) created = self._clip_config_repo.create(config_obj)
created_configs.append(created) created_configs.append(created)
@@ -843,8 +744,6 @@ class EditTemplateService:
Raises: Raises:
ValueError: 模板/版本不存在 ValueError: 模板/版本不存在
""" """
from packages.domain.template_clip_config import TemplateClipConfig
template = self.get_template_or_raise(template_id) template = self.get_template_or_raise(template_id)
# 1. 读取目标版本快照 # 1. 读取目标版本快照
@@ -857,22 +756,7 @@ class EditTemplateService:
try: try:
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销 # 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
old_clip_configs = self._clip_config_repo.list_by_template(template_id) old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [ old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion from packages.domain.template_version import EditTemplateVersion
@@ -905,37 +789,7 @@ class EditTemplateService:
synchronize_session=False synchronize_session=False
) )
for clip_snap in target_version.clip_configs: for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip_snap.get("clip_type", "main"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import ClipType
clip_type = ClipType.MAIN
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip_snap.get("order", 0),
min_duration=clip_snap.get("min_duration", 0.0),
max_duration=clip_snap.get("max_duration", 0.0),
text_template=clip_snap.get("text_template", ""),
transition_effect=transition,
config=clip_snap.get("config", {}) or {},
)
self._clip_config_repo.create(config_obj) self._clip_config_repo.create(config_obj)
self._db.commit() self._db.commit()
@@ -27,13 +27,12 @@ from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.edit_template import EditTemplate from packages.domain.edit_template import EditTemplate
from packages.domain.editing_mode import EditingMode from packages.domain.editing_mode import EditingMode
from packages.domain.plan_generator_utils import ( from packages.domain.plan_generator_utils import (
DEFAULT_CLIP_DURATION,
create_clips_from_configs, create_clips_from_configs,
distribute_assets, distribute_assets,
generate_default_clips, generate_default_clips,
map_clip_types_for_mode, map_clip_types_for_mode,
) )
from packages.domain.template_clip_config import ClipType, TemplateClipConfig from packages.domain.template_clip_config import TemplateClipConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -206,4 +205,3 @@ class PlanGeneratorService:
委托给 plan_generator_utils.distribute_assets 纯函数。 委托给 plan_generator_utils.distribute_assets 纯函数。
""" """
distribute_assets(clips, asset_ids, editing_mode) distribute_assets(clips, asset_ids, editing_mode)
@@ -21,14 +21,9 @@ from __future__ import annotations
import logging import logging
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests
from packages.domain.asset_scoring import ( from packages.domain.asset_scoring import (
MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX,
MIN_QUALITY_SCORE as _MIN_QUALITY_SCORE,
OPTIMAL_DURATION_MAX as _OPTIMAL_DURATION_MAX,
OPTIMAL_DURATION_MIN as _OPTIMAL_DURATION_MIN,
SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX,
TARGET_HEIGHT as _TARGET_HEIGHT,
TARGET_WIDTH as _TARGET_WIDTH,
AssetScoreDetail, AssetScoreDetail,
SmartSelectResult, SmartSelectResult,
diverse_selection, diverse_selection,
+30 -245
View File
@@ -29,47 +29,33 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
) )
from packages.domain.edit_plan import EditPlanStatus from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.template_clip_config import TransitionEffect from packages.domain.video_filter_builder import (
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
ClipFilterChain,
build_clip_filter,
)
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
from packages.domain.video_filter_builder import has_audio as _has_audio_func
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 常量 ────────────────────────────────────────────────────────────────────── # ── 常量(向后兼容别名) ──────────────────────────────────────────────────────
# 实际定义已迁移至 packages/domain/video_filter_builder.py
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
DEFAULT_CODEC = "libx264" DEFAULT_CODEC = "libx264"
DEFAULT_CRF = 23 DEFAULT_CRF = 23
DEFAULT_PRESET = "medium" DEFAULT_PRESET = "medium"
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
_XFADE_TRANSITION_MAP: dict[str, str] = {
TransitionEffect.FADE: "fade",
TransitionEffect.SLIDE_LEFT: "slideleft",
TransitionEffect.SLIDE_RIGHT: "slideright",
TransitionEffect.DISSOLVE: "dissolve",
TransitionEffect.WIPE: "wipeleft",
}
# 转场默认时长(秒)
DEFAULT_TRANSITION_DURATION = 0.5
# ── 数据结构 ────────────────────────────────────────────────────────────────── # ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ClipFilterChain:
"""单个片段的滤镜链描述。"""
clip_id: str
input_index: int
video_label: str
audio_label: str | None
filters: list[str]
duration: float
@dataclass(frozen=True) @dataclass(frozen=True)
class ComposeCommand: class ComposeCommand:
"""完整的 FFmpeg 合成命令描述。""" """完整的 FFmpeg 合成命令描述。"""
@@ -401,62 +387,8 @@ class VideoComposeService:
output_height: int, output_height: int,
fps: int, fps: int,
) -> ClipFilterChain: ) -> ClipFilterChain:
"""为单个片段构建滤镜链。 """向后兼容:委托给 video_filter_builder.build_clip_filter。"""
return build_clip_filter(clip, input_index, output_width, output_height, fps)
滤镜顺序:
1. scale — 等比缩放到目标分辨率(保证覆盖)
2. crop — 居中裁剪到目标分辨率
3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
4. setpts — 重置时间戳 + 偏移
5. trim — 视频时长裁剪
6. atrim — 音频时长裁剪(如有音频流)
"""
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time
filters: list[str] = []
# 1. scale: 等比缩放(保持比例,不裁剪)
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
# 3. format: 统一像素格式为 yuv420pH.264 标准格式,concat 要求所有输入像素格式一致)
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
filters.append("format=yuv420p")
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
if fps and fps > 0:
filters.append(f"fps={fps}")
# 3. setpts: 重置时间戳
if start > 0:
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
else:
filters.append("setpts=PTS-STARTPTS")
# 4. trim: 视频时长
filters.append(f"trim=0:{duration}")
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
video_label = f"v{input_index}"
# 5. 音频标签:仅当片段类型可能有音频时才设置
# title/subtitle 是纯文字/图片卡片,没有音频流
clip_type = clip.clip_type.lower() if clip.clip_type else ""
has_audio_stream = clip_type not in ("title", "subtitle")
audio_label = f"a{input_index}" if has_audio_stream else None
return ClipFilterChain(
clip_id=clip.id,
input_index=input_index,
video_label=video_label,
audio_label=audio_label,
filters=filters,
duration=duration,
)
@staticmethod @staticmethod
def _build_filter_complex( def _build_filter_complex(
@@ -466,102 +398,30 @@ class VideoComposeService:
transition_duration: float, transition_duration: float,
transitions: list[str], transitions: list[str],
) -> tuple[str, float]: ) -> tuple[str, float]:
"""构建完整的 filter_complex 字符串。 """向后兼容:委托给 video_filter_builder.build_filter_complex"""
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
策略:
- 单片段:直接输出
- 多片段 + 全 cut:使用 concat 滤镜(高效)
- 多片段 + 有转场:使用 xfade 滤镜链
返回 (filter_complex_string, estimated_total_duration)。
"""
n = len(clip_chains)
if n == 0:
return "", 0.0
# ── 单片段 ─────────────────────────────────────────────────────
if n == 1:
chain = clip_chains[0]
filter_str = _chain_filters(chain.filters, chain.video_label)
# 音频
if chain.audio_label:
filter_str += f";[0:a]{chain.audio_label}"
total_duration = chain.duration
return filter_str, total_duration
# ── 检查是否有转场 ─────────────────────────────────────────────
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
if not has_transitions:
return _build_concat_filter(clip_chains)
# ── 有转场:使用 xfade ─────────────────────────────────────────
return _build_xfade_filter(
clip_chains=clip_chains,
transition_duration=transition_duration,
transitions=transitions,
)
@staticmethod @staticmethod
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool: def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
"""是否有任何片段包含音频流""" """向后兼容:委托给 video_filter_builder.has_audio"""
return any(c.audio_label is not None for c in clip_chains) return _has_audio_func(clip_chains)
# ── 模块级辅助函数 ──────────────────────────────────────────────────────────── # ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
# 实际实现已迁移至 packages/domain/video_filter_builder.py
# 保留此处别名以兼容现有测试与调用方
def _chain_filters(filters: list[str], output_label: str) -> str: def _chain_filters(filters: list[str], output_label: str) -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串""" """向后兼容:委托给 video_filter_builder.chain_filters"""
filter_body = ",".join(filters) return _chain_filters_func(filters, output_label)
return f"[0:v]{filter_body}[{output_label}]"
def _build_concat_filter( def _build_concat_filter(
clip_chains: list[ClipFilterChain], clip_chains: list[ClipFilterChain],
) -> tuple[str, float]: ) -> tuple[str, float]:
"""构建 concat 滤镜(无转场,高效拼接)。 """向后兼容:委托给 video_filter_builder.build_concat_filter。"""
return _build_concat_filter_func(clip_chains)
格式:
[0:v]filters[v0]; [1:v]filters[v1]; ...
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# concat 滤镜
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
parts.append(concat_filter)
# 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
audio_parts: list[str] = []
for idx, chain in enumerate(clip_chains):
if chain.audio_label:
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]")
if audio_parts:
parts.extend(audio_parts)
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
audio_count = sum(1 for c in clip_chains if c.audio_label)
if audio_count > 0:
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
return ";".join(parts), total_duration
def _build_xfade_filter( def _build_xfade_filter(
@@ -569,80 +429,5 @@ def _build_xfade_filter(
transition_duration: float, transition_duration: float,
transitions: list[str], transitions: list[str],
) -> tuple[str, float]: ) -> tuple[str, float]:
"""构建 xfade 转场滤镜链。 """向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
每两个相邻片段之间插入 xfade 转场。
offset = 前一个片段的累积时长 - 转场时长。
格式(2 片段):
[0:v]filters[v0]; [1:v]filters[v1];
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
格式(3+ 片段):
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# xfade 链
if n == 1:
# 单片段不需要 xfade
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
return ";".join(parts), total_duration
# 计算每个转场的 offset
cumulative = 0.0
prev_label = clip_chains[0].video_label
for i in range(1, n):
cumulative += clip_chains[i - 1].duration
offset = max(0.0, cumulative - transition_duration * i)
# 获取转场类型
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
if i == n - 1:
# 最后一个转场,输出到 [outv]
out_label = "outv"
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_chains[i].video_label}]"
f"xfade=transition={xfade_transition}"
f":duration={transition_duration}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
# 总时长需要减去转场重叠部分
total_duration -= transition_duration * (n - 1)
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
if len(audio_chains_with_label) >= 2:
normalized_audio_labels: list[str] = []
for chain, _ in audio_chains_with_label:
norm_label = f"anorm_{chain.video_label}"
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
normalized_audio_labels.append(norm_label)
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
elif len(audio_chains_with_label) == 1:
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration)
+19
View File
@@ -0,0 +1,19 @@
/**
* 任务相关 API — 目录化入口
* 保持与原 tasks.ts 相同导出,向后兼容
*/
// 类型
export type {
TaskStatus,
TaskType,
TaskErrorInfo,
TaskItem,
TaskListParams,
TaskListResponse,
CreateGenerationTaskRequest,
CreateGenerationTaskResponse,
} from "./types"
// API 函数
export { createGenerationTask, getTasks, getUserTasks, getTask, retryTask } from "./tasks"
+46
View File
@@ -0,0 +1,46 @@
/**
* 任务相关 API 函数
* 对接后端任务中心 API
*/
import apiClient from "../client"
import type {
CreateGenerationTaskRequest,
CreateGenerationTaskResponse,
TaskItem,
TaskListParams,
TaskListResponse,
} from "./types"
/** 创建生成任务(智能剪辑) */
export const createGenerationTask = async (
params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
return data
}
/** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params,
})
return data
}
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks")
return data.items || data || []
}
/** 获取单个任务详情(含 error_info */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`)
return data
}
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
return data
}
@@ -1,14 +1,6 @@
/** /**
* API *
* API
* - POST /api/v1/generation/tasks
* - GET /api/v1/tasks /
* - GET /api/v1/tasks/{task_id} error_info
* - POST /api/v1/tasks/{task_id}/retry
*/ */
import apiClient from "./client"
/* ──────────── 类型定义 ──────────── */
/** 任务状态 */ /** 任务状态 */
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled" export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
@@ -85,39 +77,3 @@ export interface CreateGenerationTaskResponse {
result_count: number result_count: number
error_message: string error_message: string
} }
/* ──────────── API 函数 ──────────── */
/** 创建生成任务(智能剪辑) */
export const createGenerationTask = async (
params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
return data
}
/** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params,
})
return data
}
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks")
return data.items || data || []
}
/** 获取单个任务详情(含 error_info */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`)
return data
}
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
return data
}
@@ -1,15 +1,21 @@
/** /**
* 模板编辑器 — 制作/编辑剪辑模板 * 模板编辑器 — 制作/编辑剪辑模板
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*
* 主组件仅保留 Hook 组装与整体布局
* 全局配置 → hooks/useGlobalSettings
* 配音素材 → hooks/useVoiceMaterials
* 撤销重做 → hooks/useUndoRedo
* 抽屉管理 → hooks/useEditorDrawers
* 播放控制 → hooks/usePlaybackControl
* 片段操作 → hooks/useClipOperations
* 模板管理 → hooks/useTemplateManagement
*/ */
import React, { useState } from "react" import React, { useState } from "react"
import { useSearchParams } from "react-router-dom" import { useSearchParams } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { MODE_LABELS } from "@/api/editing-planner" import { MODE_LABELS } from "@/api/editing-planner"
import { MODE_LIST } from "./constants" import { MODE_LIST } from "./constants"
import type { MediaAsset, TitleConfig } from "@/api/template-editor" import type { MediaAsset } from "@/api/template-editor"
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import MediaPanel from "./components/MediaPanel" import MediaPanel from "./components/MediaPanel"
import PreviewPlayer from "./components/PreviewPlayer" import PreviewPlayer from "./components/PreviewPlayer"
@@ -26,36 +32,12 @@ import { useEditorDrawers } from "./hooks/useEditorDrawers"
import { usePlaybackControl } from "./hooks/usePlaybackControl" import { usePlaybackControl } from "./hooks/usePlaybackControl"
import { useClipOperations } from "./hooks/useClipOperations" import { useClipOperations } from "./hooks/useClipOperations"
import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement" import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement"
import { useGlobalSettings } from "./hooks/useGlobalSettings"
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
import type { import type { ClipData } from "./types"
ClipData,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "./types"
import {
DEFAULT_WATERMARK,
DEFAULT_INTRO_OUTRO,
DEFAULT_PIP_CONFIG,
DEFAULT_FILTER_CONFIG,
DEFAULT_CHROMA_KEY_CONFIG,
DEFAULT_STICKER_CONFIG,
DEFAULT_COVER_CONFIG,
} from "./types"
import type { SubtitleStyleConfig } from "./types/subtitle"
import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle"
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
import "./EditingPlanner.css" import "./EditingPlanner.css"
/* ──────────── 常量 ──────────── */
/* ──────────── 组件 ──────────── */
const EditingPlanner: React.FC = () => { const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const urlTemplateId = searchParams.get("templateId") || "" const urlTemplateId = searchParams.get("templateId") || ""
@@ -72,50 +54,29 @@ const EditingPlanner: React.FC = () => {
reset: resetClips, reset: resetClips,
} = useUndoRedo<ClipData[]>([]) } = useUndoRedo<ClipData[]>([])
/* ── 全局配置 state ── */ /* ── 全局配置 ── */
const [titleConfig, setTitleConfig] = useState<TitleConfig>({ const {
ai_auto_select: false, titleConfig,
content: "", setTitleConfig,
position: "bottom", subtitleSettings,
font_preset: "思源黑体", setSubtitleSettings,
font_size: 28, bgmSettings,
font_color: "#ffffff", setBgmSettings,
}) watermarkSettings,
setWatermarkSettings,
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({ introOutroSettings,
...DEFAULT_SUBTITLE_STYLE, setIntroOutroSettings,
}) pipSettings,
setPipSettings,
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({ filterSettings,
...DEFAULT_BGM_MIX_CONFIG, setFilterSettings,
}) chromaKeySettings,
setChromaKeySettings,
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({ stickerSettings,
...DEFAULT_WATERMARK, setStickerSettings,
}) coverConfig,
const [introOutroSettings, setIntroOutroSettings] = useState<IntroOutroConfig>({ setCoverConfig,
...DEFAULT_INTRO_OUTRO, } = useGlobalSettings()
})
const [pipSettings, setPipSettings] = useState<PipConfig>({
...DEFAULT_PIP_CONFIG,
})
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
...DEFAULT_FILTER_CONFIG,
})
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
...DEFAULT_CHROMA_KEY_CONFIG,
})
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
...DEFAULT_STICKER_CONFIG,
})
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
...DEFAULT_COVER_CONFIG,
})
/* ── 右侧栏 Tab ── */ /* ── 右侧栏 Tab ── */
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties") const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
@@ -128,18 +89,12 @@ const EditingPlanner: React.FC = () => {
setSelectedAssetIds(ids) setSelectedAssetIds(ids)
} }
/* ── 配音素材queryKey 与 VoiceMaterialLibrary 共享缓存) ── */ /* ── 配音素材 ── */
const voiceMaterialsQuery = useQuery({ const {
queryKey: ["assets", "voice"], voiceMaterials,
queryFn: async () => { loading: voiceMaterialsLoading,
const project = await getOrCreateDefaultProject() refetch: refetchVoiceMaterials,
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" }) } = useVoiceMaterials()
const assets = await getAssetsByKind("voice")
return assets
},
staleTime: 30_000,
})
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? []
/* ── 派生计算 ── */ /* ── 派生计算 ── */
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0) const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0)
@@ -179,31 +134,6 @@ const EditingPlanner: React.FC = () => {
coverConfig, coverConfig,
}) })
/* ── 配置变更 handlers ── */
const handleWatermarkChange = (config: WatermarkConfig) => {
setWatermarkSettings(config)
}
const handleIntroOutroChange = (config: IntroOutroConfig) => {
setIntroOutroSettings(config)
}
const handlePipChange = (config: PipConfig) => {
setPipSettings(config)
}
const handleFilterChange = (config: FilterConfig) => {
setFilterSettings(config)
}
const handleChromaKeyChange = (config: ChromaKeyConfig) => {
setChromaKeySettings(config)
}
const handleStickerChange = (config: StickerConfig) => {
setStickerSettings(config)
}
/* ──────────── 渲染 ──────────── */ /* ──────────── 渲染 ──────────── */
return ( return (
@@ -294,15 +224,15 @@ const EditingPlanner: React.FC = () => {
totalDuration={totalDuration} totalDuration={totalDuration}
currentMode={tpl.currentMode} currentMode={tpl.currentMode}
onSubtitleSettingsChange={(partial) => onSubtitleSettingsChange={(partial) =>
setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig) setSubtitleSettings((prev) => ({ ...prev, ...partial }))
} }
onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))} onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))}
onClipUpdate={clipOps.handleClipUpdate} onClipUpdate={clipOps.handleClipUpdate}
onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)} onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)}
onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)} onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)}
voiceMaterials={voiceMaterials} voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsQuery.isLoading} voiceMaterialsLoading={voiceMaterialsLoading}
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()} onRefreshVoiceMaterials={refetchVoiceMaterials}
onClipVoiceSelect={clipOps.handleClipVoiceSelect} onClipVoiceSelect={clipOps.handleClipVoiceSelect}
onOpenTransitionDrawer={drawers.openTransitionDrawer} onOpenTransitionDrawer={drawers.openTransitionDrawer}
onOpenSpeedDrawer={drawers.openSpeedDrawer} onOpenSpeedDrawer={drawers.openSpeedDrawer}
@@ -382,28 +312,28 @@ const EditingPlanner: React.FC = () => {
onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)} onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)}
watermarkDrawerOpen={drawers.watermarkDrawerOpen} watermarkDrawerOpen={drawers.watermarkDrawerOpen}
watermarkSettings={watermarkSettings} watermarkSettings={watermarkSettings}
onWatermarkChange={handleWatermarkChange} onWatermarkChange={setWatermarkSettings}
onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)} onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)}
introOutroDrawerOpen={drawers.introOutroDrawerOpen} introOutroDrawerOpen={drawers.introOutroDrawerOpen}
introOutroSettings={introOutroSettings} introOutroSettings={introOutroSettings}
onIntroOutroChange={handleIntroOutroChange} onIntroOutroChange={setIntroOutroSettings}
onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)} onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)}
pipDrawerOpen={drawers.pipDrawerOpen} pipDrawerOpen={drawers.pipDrawerOpen}
pipSettings={pipSettings} pipSettings={pipSettings}
totalDuration={totalDuration} totalDuration={totalDuration}
onPipChange={handlePipChange} onPipChange={setPipSettings}
onClosePipDrawer={() => drawers.setPipDrawerOpen(false)} onClosePipDrawer={() => drawers.setPipDrawerOpen(false)}
filterDrawerOpen={drawers.filterDrawerOpen} filterDrawerOpen={drawers.filterDrawerOpen}
filterSettings={filterSettings} filterSettings={filterSettings}
onFilterChange={handleFilterChange} onFilterChange={setFilterSettings}
onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)} onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)}
chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen} chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen}
chromaKeySettings={chromaKeySettings} chromaKeySettings={chromaKeySettings}
onChromaKeyChange={handleChromaKeyChange} onChromaKeyChange={setChromaKeySettings}
onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)} onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)}
stickerDrawerOpen={drawers.stickerDrawerOpen} stickerDrawerOpen={drawers.stickerDrawerOpen}
stickerSettings={stickerSettings} stickerSettings={stickerSettings}
onStickerChange={handleStickerChange} onStickerChange={setStickerSettings}
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)} onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
/> />
</div> </div>
@@ -3,189 +3,32 @@
* 纯渲染层,业务逻辑和 state 留在父组件 * 纯渲染层,业务逻辑和 state 留在父组件
*/ */
import React from "react" import React from "react"
import type { TemplateCategory } from "@/api/editing-planner"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../types"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import SaveModal from "./SaveModal" import SaveModal from "./SaveModal"
import BgmSelector from "./BgmSelector" import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers"
import SubtitleStylePanel from "./SubtitleStylePanel" import { GlobalDrawers } from "./editing-drawers/GlobalDrawers"
import TransitionSelector from "./TransitionSelector" import type { EditingDrawersProps } from "./editing-drawers/types"
import SpeedPanel from "./SpeedPanel"
import TtsPanel from "./TtsPanel"
import WatermarkPanel from "./WatermarkPanel"
import IntroOutroPanel from "./IntroOutroPanel"
import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
interface EditingDrawersProps { const EditingDrawers: React.FC<EditingDrawersProps> = (props) => {
/* 保存弹窗 */ const {
saveModalOpen: boolean saveModalOpen,
saveLoading: boolean saveLoading,
isUpdate: boolean isUpdate,
draftName: string draftName,
draftCategory: string draftCategory,
draftTags: string draftTags,
categories: TemplateCategory[] categories,
estimatedDuration: number estimatedDuration,
onNameChange: (name: string) => void onNameChange,
onCategoryChange: (cat: string) => void onCategoryChange,
onTagsChange: (tags: string) => void onTagsChange,
onSave: () => Promise<void> onSave,
onCancelSave: () => void onCancelSave,
/* BGM */ clips,
bgmDrawerOpen: boolean } = props
bgmSettings: BgmMixConfig
onCloseBgmDrawer: () => void
onChangeBgmSettings: (config: BgmMixConfig) => void
/* 字幕 */
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onCloseSubtitleDrawer: () => void
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
/* 转场 */
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
onCloseTransitionDrawer: () => void
onTransitionChange: (config: TransitionConfig) => void
/* 调速 */
speedDrawerOpen: boolean
speedTargetClipId: string | null
onCloseSpeedDrawer: () => void
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
/* TTS 配音 */
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onCloseTtsDrawer: () => void
onTtsChange: (config: TtsConfig) => void
/* 水印 */
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onCloseWatermarkDrawer: () => void
onWatermarkChange: (config: WatermarkConfig) => void
/* 片头片尾 */
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onCloseIntroOutroDrawer: () => void
onIntroOutroChange: (config: IntroOutroConfig) => void
/* 混剪 */
pipDrawerOpen: boolean
pipSettings: PipConfig
onClosePipDrawer: () => void
onPipChange: (config: PipConfig) => void
/* 滤镜调色 */
filterDrawerOpen: boolean
filterSettings: FilterConfig
onCloseFilterDrawer: () => void
onFilterChange: (config: FilterConfig) => void
/* 绿幕抠像 */
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onCloseChromaKeyDrawer: () => void
onChromaKeyChange: (config: ChromaKeyConfig) => void
/* 贴纸 */
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onCloseStickerDrawer: () => void
onStickerChange: (config: StickerConfig) => void
/* 共享数据 */
clips: ClipData[]
totalDuration: number
}
const EditingDrawers: React.FC<EditingDrawersProps> = ({
saveModalOpen,
saveLoading,
isUpdate,
draftName,
draftCategory,
draftTags,
categories,
estimatedDuration,
onNameChange,
onCategoryChange,
onTagsChange,
onSave,
onCancelSave,
bgmDrawerOpen,
bgmSettings,
onCloseBgmDrawer,
onChangeBgmSettings,
subtitleDrawerOpen,
subtitleSettings,
onCloseSubtitleDrawer,
onChangeSubtitleSettings,
transitionDrawerOpen,
transitionTargetClipId,
onCloseTransitionDrawer,
onTransitionChange,
speedDrawerOpen,
speedTargetClipId,
onCloseSpeedDrawer,
onSpeedChange,
onApplySpeedAll,
ttsDrawerOpen,
ttsTargetClipId,
onCloseTtsDrawer,
onTtsChange,
watermarkDrawerOpen,
watermarkSettings,
onCloseWatermarkDrawer,
onWatermarkChange,
introOutroDrawerOpen,
introOutroSettings,
onCloseIntroOutroDrawer,
onIntroOutroChange,
pipDrawerOpen,
pipSettings,
onClosePipDrawer,
onPipChange,
filterDrawerOpen,
filterSettings,
onCloseFilterDrawer,
onFilterChange,
chromaKeyDrawerOpen,
chromaKeySettings,
onCloseChromaKeyDrawer,
onChromaKeyChange,
stickerDrawerOpen,
stickerSettings,
onCloseStickerDrawer,
onStickerChange,
clips,
totalDuration,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
const speedConfig = speedTargetClipId
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
: DEFAULT_SPEED
const ttsConfig = ttsTargetClipId
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
: DEFAULT_TTS_CONFIG
return ( return (
<> <>
{/* ═══ 保存弹窗 ═══ */} {/* 保存弹窗 */}
<SaveModal <SaveModal
open={saveModalOpen} open={saveModalOpen}
loading={saveLoading} loading={saveLoading}
@@ -202,100 +45,59 @@ const EditingDrawers: React.FC<EditingDrawersProps> = ({
onCancel={onCancelSave} onCancel={onCancelSave}
/> />
{/* ═══ BGM 选择器 Drawer ═══ */} {/* 片段级抽屉(转场/调速/TTS */}
<BgmSelector <ClipLevelDrawers
open={bgmDrawerOpen} clips={clips}
onClose={onCloseBgmDrawer} transitionDrawerOpen={props.transitionDrawerOpen}
config={bgmSettings} transitionTargetClipId={props.transitionTargetClipId}
onChange={onChangeBgmSettings} onCloseTransitionDrawer={props.onCloseTransitionDrawer}
onTransitionChange={props.onTransitionChange}
speedDrawerOpen={props.speedDrawerOpen}
speedTargetClipId={props.speedTargetClipId}
onCloseSpeedDrawer={props.onCloseSpeedDrawer}
onSpeedChange={props.onSpeedChange}
onApplySpeedAll={props.onApplySpeedAll}
ttsDrawerOpen={props.ttsDrawerOpen}
ttsTargetClipId={props.ttsTargetClipId}
onCloseTtsDrawer={props.onCloseTtsDrawer}
onTtsChange={props.onTtsChange}
/> />
{/* ═══ 字幕样式配置 Drawer ═══ */} {/* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) */}
<SubtitleStylePanel <GlobalDrawers
open={subtitleDrawerOpen} bgmDrawerOpen={props.bgmDrawerOpen}
onClose={onCloseSubtitleDrawer} bgmSettings={props.bgmSettings}
config={subtitleSettings} onCloseBgmDrawer={props.onCloseBgmDrawer}
onChange={onChangeSubtitleSettings} onChangeBgmSettings={props.onChangeBgmSettings}
/> subtitleDrawerOpen={props.subtitleDrawerOpen}
subtitleSettings={props.subtitleSettings}
{/* ═══ 转场特效选择器 Drawer ═══ */} onCloseSubtitleDrawer={props.onCloseSubtitleDrawer}
<TransitionSelector onChangeSubtitleSettings={props.onChangeSubtitleSettings}
open={transitionDrawerOpen} totalDuration={props.totalDuration}
onClose={onCloseTransitionDrawer} watermarkDrawerOpen={props.watermarkDrawerOpen}
config={transitionConfig} watermarkSettings={props.watermarkSettings}
onChange={onTransitionChange} onCloseWatermarkDrawer={props.onCloseWatermarkDrawer}
title={transitionTitle} onWatermarkChange={props.onWatermarkChange}
/> introOutroDrawerOpen={props.introOutroDrawerOpen}
introOutroSettings={props.introOutroSettings}
{/* ═══ 片段调速面板 Drawer ═══ */} onCloseIntroOutroDrawer={props.onCloseIntroOutroDrawer}
{speedTargetClipId && ( onIntroOutroChange={props.onIntroOutroChange}
<SpeedPanel pipDrawerOpen={props.pipDrawerOpen}
open={speedDrawerOpen} pipSettings={props.pipSettings}
onClose={onCloseSpeedDrawer} onClosePipDrawer={props.onClosePipDrawer}
config={speedConfig} onPipChange={props.onPipChange}
onChange={onSpeedChange} filterDrawerOpen={props.filterDrawerOpen}
onApplyAll={onApplySpeedAll} filterSettings={props.filterSettings}
/> onCloseFilterDrawer={props.onCloseFilterDrawer}
)} onFilterChange={props.onFilterChange}
chromaKeyDrawerOpen={props.chromaKeyDrawerOpen}
{/* ═══ TTS 配音面板 Drawer ═══ */} chromaKeySettings={props.chromaKeySettings}
{ttsTargetClipId && ( onCloseChromaKeyDrawer={props.onCloseChromaKeyDrawer}
<TtsPanel onChromaKeyChange={props.onChromaKeyChange}
open={ttsDrawerOpen} stickerDrawerOpen={props.stickerDrawerOpen}
onClose={onCloseTtsDrawer} stickerSettings={props.stickerSettings}
config={ttsConfig} onCloseStickerDrawer={props.onCloseStickerDrawer}
onChange={onTtsChange} onStickerChange={props.onStickerChange}
/>
)}
{/* ═══ 水印配置面板 ═══ */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* ═══ 片头片尾配置面板 ═══ */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* ═══ 混剪配置面板 ═══ */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* ═══ 滤镜调色面板 ═══ */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* ═══ 绿幕抠像面板 ═══ */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* ═══ 贴纸面板 ═══ */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/> />
</> </>
) )
@@ -0,0 +1,74 @@
import React from "react"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../../types"
import TransitionSelector from "../TransitionSelector"
import SpeedPanel from "../SpeedPanel"
import TtsPanel from "../TtsPanel"
import type { ClipLevelDrawersProps } from "./types"
/**
* 片段级抽屉(转场/调速/TTS)
* 这些抽屉针对特定片段,需要 targetClipId 来定位和读取当前配置
*/
export const ClipLevelDrawers: React.FC<ClipLevelDrawersProps> = ({
clips,
transitionDrawerOpen,
transitionTargetClipId,
onCloseTransitionDrawer,
onTransitionChange,
speedDrawerOpen,
speedTargetClipId,
onCloseSpeedDrawer,
onSpeedChange,
onApplySpeedAll,
ttsDrawerOpen,
ttsTargetClipId,
onCloseTtsDrawer,
onTtsChange,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
const speedConfig = speedTargetClipId
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
: DEFAULT_SPEED
const ttsConfig = ttsTargetClipId
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
: DEFAULT_TTS_CONFIG
return (
<>
{/* 转场特效选择器 */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* 片段调速面板 */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={speedConfig}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* TTS 配音面板 */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={ttsConfig}
onChange={onTtsChange}
/>
)}
</>
)
}
@@ -0,0 +1,119 @@
import React from "react"
import BgmSelector from "../BgmSelector"
import SubtitleStylePanel from "../SubtitleStylePanel"
import WatermarkPanel from "../WatermarkPanel"
import IntroOutroPanel from "../IntroOutroPanel"
import PipConfigPanel from "../PipConfigPanel"
import FilterPanel from "../FilterPanel"
import GreenScreenPanel from "../GreenScreenPanel"
import StickerPanel from "../StickerPanel"
import type { GlobalDrawersProps, BgmDrawerProps, SubtitleDrawerProps } from "./types"
/**
* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸)
*/
export const GlobalDrawers: React.FC<BgmDrawerProps & SubtitleDrawerProps & GlobalDrawersProps> = ({
bgmDrawerOpen,
bgmSettings,
onCloseBgmDrawer,
onChangeBgmSettings,
subtitleDrawerOpen,
subtitleSettings,
onCloseSubtitleDrawer,
onChangeSubtitleSettings,
totalDuration,
watermarkDrawerOpen,
watermarkSettings,
onCloseWatermarkDrawer,
onWatermarkChange,
introOutroDrawerOpen,
introOutroSettings,
onCloseIntroOutroDrawer,
onIntroOutroChange,
pipDrawerOpen,
pipSettings,
onClosePipDrawer,
onPipChange,
filterDrawerOpen,
filterSettings,
onCloseFilterDrawer,
onFilterChange,
chromaKeyDrawerOpen,
chromaKeySettings,
onCloseChromaKeyDrawer,
onChromaKeyChange,
stickerDrawerOpen,
stickerSettings,
onCloseStickerDrawer,
onStickerChange,
}) => {
return (
<>
{/* BGM 选择器 */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onChangeBgmSettings}
/>
{/* 字幕样式配置 */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onChangeSubtitleSettings}
/>
{/* 水印配置面板 */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* 片头片尾配置面板 */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* 混剪配置面板 */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* 滤镜调色面板 */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* 绿幕抠像面板 */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* 贴纸面板 */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/>
</>
)
}
@@ -0,0 +1,101 @@
import type { TemplateCategory } from "@/api/editing-planner"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
/** 保存弹窗 Props */
export interface SaveModalDrawerProps {
saveModalOpen: boolean
saveLoading: boolean
isUpdate: boolean
draftName: string
draftCategory: string
draftTags: string
categories: TemplateCategory[]
estimatedDuration: number
onNameChange: (name: string) => void
onCategoryChange: (cat: string) => void
onTagsChange: (tags: string) => void
onSave: () => Promise<void>
onCancelSave: () => void
}
/** BGM 抽屉 Props */
export interface BgmDrawerProps {
bgmDrawerOpen: boolean
bgmSettings: BgmMixConfig
onCloseBgmDrawer: () => void
onChangeBgmSettings: (config: BgmMixConfig) => void
}
/** 字幕抽屉 Props */
export interface SubtitleDrawerProps {
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onCloseSubtitleDrawer: () => void
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
}
/** 单个片段级抽屉通用 Props */
export interface ClipLevelDrawersProps {
clips: ClipData[]
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
onCloseTransitionDrawer: () => void
onTransitionChange: (config: TransitionConfig) => void
speedDrawerOpen: boolean
speedTargetClipId: string | null
onCloseSpeedDrawer: () => void
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onCloseTtsDrawer: () => void
onTtsChange: (config: TtsConfig) => void
}
/** 全局设置抽屉 Props */
export interface GlobalDrawersProps {
totalDuration: number
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onCloseWatermarkDrawer: () => void
onWatermarkChange: (config: WatermarkConfig) => void
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onCloseIntroOutroDrawer: () => void
onIntroOutroChange: (config: IntroOutroConfig) => void
pipDrawerOpen: boolean
pipSettings: PipConfig
onClosePipDrawer: () => void
onPipChange: (config: PipConfig) => void
filterDrawerOpen: boolean
filterSettings: FilterConfig
onCloseFilterDrawer: () => void
onFilterChange: (config: FilterConfig) => void
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onCloseChromaKeyDrawer: () => void
onChromaKeyChange: (config: ChromaKeyConfig) => void
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onCloseStickerDrawer: () => void
onStickerChange: (config: StickerConfig) => void
}
export type EditingDrawersProps = SaveModalDrawerProps &
BgmDrawerProps &
SubtitleDrawerProps &
ClipLevelDrawersProps &
GlobalDrawersProps
@@ -2,7 +2,12 @@
* 混剪单图层配置区 * 混剪单图层配置区
*/ */
import React from "react" import React from "react"
import type { PipLayer, PipAnimType, PipSlideDirection, PipGridPosition } from "@/pages/editing-planner/types" import type {
PipLayer,
PipAnimType,
PipSlideDirection,
PipGridPosition,
} from "@/pages/editing-planner/types"
import { import {
GRID_POSITIONS, GRID_POSITIONS,
ANIM_OPTIONS, ANIM_OPTIONS,
@@ -0,0 +1,83 @@
import { useUndoRedo } from "../useUndoRedo"
import type { EditPlanClip } from "@/api/template-editor"
import { useEditPlanClipList } from "./useEditPlanClipList"
import { useEditPlanClipMutations } from "./useEditPlanClipMutations"
/**
* 模板片段管理 Hook
* 对接后端 PR#389 片段 CRUD API
*
* 功能:
* - 加载/刷新片段列表
* - 单个增删改查
* - 批量删除
* - 拖拽重排序
* - 从素材批量导入
* - 乐观更新 + 撤销重做
*/
export function useEditPlanClips(planId: string | undefined) {
// 列表数据 + 选中状态
const {
clips,
clipsTotal,
clipsLoading,
refetchClips,
selectedClipId,
setSelectedClipId,
selectedClip,
} = useEditPlanClipList(planId)
// CRUD 操作
const mutations = useEditPlanClipMutations({
planId,
selectedClipId,
setSelectedClipId,
clipsLength: clips.length,
})
// 本地撤销重做(供拖拽等即时操作使用)
const {
state: localClips,
set: setLocalClips,
undo,
redo,
canUndo,
canRedo,
reset: resetLocalClips,
} = useUndoRedo<EditPlanClip[]>([])
return {
// 数据
clips,
clipsTotal,
clipsLoading,
selectedClipId,
selectedClip,
// 选中
setSelectedClipId,
// 操作
addClip: mutations.addClip,
updateClip: mutations.updateClip,
removeClip: mutations.removeClip,
batchRemoveClips: mutations.batchRemoveClips,
reorderClips: mutations.reorderClips,
importFromAssets: mutations.importFromAssets,
refetchClips,
// 状态
isCreating: mutations.isCreating,
isUpdating: mutations.isUpdating,
isDeleting: mutations.isDeleting,
isReordering: mutations.isReordering,
isImporting: mutations.isImporting,
// 本地撤销重做
localClips,
setLocalClips,
undo,
redo,
canUndo,
canRedo,
resetLocalClips,
}
}
export default useEditPlanClips
@@ -0,0 +1,49 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import type { EditPlanClip } from "@/api/template-editor"
import { getEditPlanClips } from "@/api/template-editor"
const QUERY_KEY = "editPlanClips"
interface UseEditPlanClipListResult {
clips: EditPlanClip[]
clipsTotal: number
clipsLoading: boolean
refetchClips: () => void
selectedClipId: string | null
setSelectedClipId: (id: string | null) => void
selectedClip: EditPlanClip | null
}
/**
* 编辑计划片段列表 Hook
* 封装片段列表查询、选中状态
*/
export function useEditPlanClipList(planId: string | undefined): UseEditPlanClipListResult {
const {
data: clipListData,
isLoading: clipsLoading,
refetch: refetchClips,
} = useQuery({
queryKey: [QUERY_KEY, planId],
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
enabled: !!planId,
staleTime: 30_000,
})
const clips: EditPlanClip[] = clipListData?.items ?? []
const clipsTotal = clipListData?.total ?? 0
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null
return {
clips,
clipsTotal,
clipsLoading,
refetchClips,
selectedClipId,
setSelectedClipId,
selectedClip,
}
}
@@ -1,26 +1,12 @@
/** import { useCallback } from "react"
* Hook
* PR#389 CRUD API config.segments
*
*
* - /
* -
* -
* -
* -
* - +
*/
import { useCallback, useState } from "react"
import { message } from "antd" import { message } from "antd"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query" import { useQueryClient, useMutation } from "@tanstack/react-query"
import type { import type {
EditPlanClip,
CreateEditPlanClipRequest, CreateEditPlanClipRequest,
UpdateEditPlanClipRequest, UpdateEditPlanClipRequest,
ClipReorderItem, ClipReorderItem,
} from "@/api/template-editor" } from "@/api/template-editor"
import { import {
getEditPlanClips,
createEditPlanClip, createEditPlanClip,
updateEditPlanClip, updateEditPlanClip,
deleteEditPlanClip, deleteEditPlanClip,
@@ -28,51 +14,37 @@ import {
batchDeleteEditPlanClips, batchDeleteEditPlanClips,
createClipsFromAssets, createClipsFromAssets,
} from "@/api/template-editor" } from "@/api/template-editor"
import { useUndoRedo } from "./useUndoRedo"
const QUERY_KEY = "editPlanClips" const QUERY_KEY = "editPlanClips"
export function useEditPlanClips(planId: string | undefined) { interface UseEditPlanClipMutationsOptions {
planId: string | undefined
selectedClipId: string | null
setSelectedClipId: (id: string | null) => void
clipsLength: number
}
/**
* CRUD Hook
*
*/
export function useEditPlanClipMutations({
planId,
selectedClipId,
setSelectedClipId,
clipsLength,
}: UseEditPlanClipMutationsOptions) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
/* ── 片段列表查询 ── */ const invalidate = () => {
const { queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
data: clipListData, }
isLoading: clipsLoading,
refetch: refetchClips,
} = useQuery({
queryKey: [QUERY_KEY, planId],
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
enabled: !!planId,
staleTime: 30_000,
})
const clips: EditPlanClip[] = clipListData?.items ?? []
const clipsTotal = clipListData?.total ?? 0
/* ── 选中片段 ── */
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null
/* ── 本地撤销/重做(用于拖拽等即时操作的回退) ── */
const {
state: localClips,
set: setLocalClips,
undo,
redo,
canUndo,
canRedo,
reset: resetLocalClips,
} = useUndoRedo<EditPlanClip[]>([])
// 当服务端数据变化时同步本地
// 注意:实际使用时以服务端为准,本地仅用于拖拽等临时操作
/* ── 创建片段 ── */ /* ── 创建片段 ── */
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data), mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) invalidate()
message.success("片段已添加") message.success("片段已添加")
}, },
onError: () => { onError: () => {
@@ -83,10 +55,10 @@ export function useEditPlanClips(planId: string | undefined) {
const addClip = useCallback( const addClip = useCallback(
(data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => { (data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => {
if (!planId) return if (!planId) return
const order = data.order ?? clips.length const order = data.order ?? clipsLength
createMutation.mutate({ ...data, order }) createMutation.mutate({ ...data, order })
}, },
[planId, clips.length, createMutation], [planId, clipsLength, createMutation],
) )
/* ── 更新片段 ── */ /* ── 更新片段 ── */
@@ -94,7 +66,7 @@ export function useEditPlanClips(planId: string | undefined) {
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) => mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
updateEditPlanClip(planId!, clipId, data), updateEditPlanClip(planId!, clipId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) invalidate()
}, },
onError: () => { onError: () => {
message.error("更新片段失败") message.error("更新片段失败")
@@ -113,7 +85,7 @@ export function useEditPlanClips(planId: string | undefined) {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId), mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) invalidate()
message.success("片段已删除") message.success("片段已删除")
}, },
onError: () => { onError: () => {
@@ -129,14 +101,14 @@ export function useEditPlanClips(planId: string | undefined) {
} }
deleteMutation.mutate(clipId) deleteMutation.mutate(clipId)
}, },
[planId, selectedClipId, deleteMutation], [planId, selectedClipId, setSelectedClipId, deleteMutation],
) )
/* ── 批量删除 ── */ /* ── 批量删除 ── */
const batchDeleteMutation = useMutation({ const batchDeleteMutation = useMutation({
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds), mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
onSuccess: (res) => { onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) invalidate()
message.success(`已删除 ${res.deleted_count} 个片段`) message.success(`已删除 ${res.deleted_count} 个片段`)
}, },
onError: () => { onError: () => {
@@ -152,19 +124,18 @@ export function useEditPlanClips(planId: string | undefined) {
} }
batchDeleteMutation.mutate(clipIds) batchDeleteMutation.mutate(clipIds)
}, },
[planId, selectedClipId, batchDeleteMutation], [planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
) )
/* ── 重排序(拖拽结束后一次性提交) ── */ /* ── 重排序 ── */
const reorderMutation = useMutation({ const reorderMutation = useMutation({
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items), mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) invalidate()
}, },
onError: () => { onError: () => {
message.error("排序失败") message.error("排序失败")
// 失败后刷新回服务端状态 invalidate()
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
}, },
}) })
@@ -180,7 +151,7 @@ export function useEditPlanClips(planId: string | undefined) {
const importFromAssetsMutation = useMutation({ const importFromAssetsMutation = useMutation({
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds), mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
onSuccess: (res) => { onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) invalidate()
message.success(`已导入 ${res.created_count} 个素材片段`) message.success(`已导入 ${res.created_count} 个素材片段`)
}, },
onError: () => { onError: () => {
@@ -197,37 +168,16 @@ export function useEditPlanClips(planId: string | undefined) {
) )
return { return {
// 数据
clips,
clipsTotal,
clipsLoading,
selectedClipId,
selectedClip,
// 选中
setSelectedClipId,
// 操作
addClip, addClip,
updateClip, updateClip,
removeClip, removeClip,
batchRemoveClips, batchRemoveClips,
reorderClips, reorderClips,
importFromAssets, importFromAssets,
refetchClips,
// 状态
isCreating: createMutation.isPending, isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending, isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending, isDeleting: deleteMutation.isPending,
isReordering: reorderMutation.isPending, isReordering: reorderMutation.isPending,
isImporting: importFromAssetsMutation.isPending, isImporting: importFromAssetsMutation.isPending,
// 本地撤销重做(供拖拽等场景使用)
localClips,
setLocalClips,
undo,
redo,
canUndo,
canRedo,
resetLocalClips,
} }
} }
export default useEditPlanClips
@@ -0,0 +1,121 @@
/**
* EditingPlanner 全局配置状态管理
* 集中管理 9 个全局配置:标题/字幕/BGM/水印/片头片尾/画中画/滤镜/绿幕/贴纸/封面
*/
import { useState } from "react"
import type { TitleConfig } from "@/api/template-editor"
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "../types"
import {
DEFAULT_WATERMARK,
DEFAULT_INTRO_OUTRO,
DEFAULT_PIP_CONFIG,
DEFAULT_FILTER_CONFIG,
DEFAULT_CHROMA_KEY_CONFIG,
DEFAULT_STICKER_CONFIG,
DEFAULT_COVER_CONFIG,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle"
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
export interface GlobalSettings {
titleConfig: TitleConfig
setTitleConfig: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
subtitleSettings: SubtitleStyleConfig
setSubtitleSettings: (
settings: SubtitleStyleConfig | ((prev: SubtitleStyleConfig) => SubtitleStyleConfig),
) => void
bgmSettings: BgmMixConfig
setBgmSettings: (settings: BgmMixConfig | ((prev: BgmMixConfig) => BgmMixConfig)) => void
watermarkSettings: WatermarkConfig
setWatermarkSettings: (config: WatermarkConfig) => void
introOutroSettings: IntroOutroConfig
setIntroOutroSettings: (config: IntroOutroConfig) => void
pipSettings: PipConfig
setPipSettings: (config: PipConfig) => void
filterSettings: FilterConfig
setFilterSettings: (config: FilterConfig) => void
chromaKeySettings: ChromaKeyConfig
setChromaKeySettings: (config: ChromaKeyConfig) => void
stickerSettings: StickerConfig
setStickerSettings: (config: StickerConfig) => void
coverConfig: CoverConfig
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
}
export const useGlobalSettings = (): GlobalSettings => {
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
ai_auto_select: false,
content: "",
position: "bottom",
font_preset: "思源黑体",
font_size: 28,
font_color: "#ffffff",
})
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({
...DEFAULT_SUBTITLE_STYLE,
})
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
...DEFAULT_BGM_MIX_CONFIG,
})
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
...DEFAULT_WATERMARK,
})
const [introOutroSettings, setIntroOutroSettings] = useState<IntroOutroConfig>({
...DEFAULT_INTRO_OUTRO,
})
const [pipSettings, setPipSettings] = useState<PipConfig>({
...DEFAULT_PIP_CONFIG,
})
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
...DEFAULT_FILTER_CONFIG,
})
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
...DEFAULT_CHROMA_KEY_CONFIG,
})
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
...DEFAULT_STICKER_CONFIG,
})
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
...DEFAULT_COVER_CONFIG,
})
return {
titleConfig,
setTitleConfig,
subtitleSettings,
setSubtitleSettings,
bgmSettings,
setBgmSettings,
watermarkSettings,
setWatermarkSettings,
introOutroSettings,
setIntroOutroSettings,
pipSettings,
setPipSettings,
filterSettings,
setFilterSettings,
chromaKeySettings,
setChromaKeySettings,
stickerSettings,
setStickerSettings,
coverConfig,
setCoverConfig,
}
}
@@ -0,0 +1,32 @@
/**
* EditingPlanner 配音素材数据加载
* queryKey 与 VoiceMaterialLibrary 共享缓存
*/
import { useQuery } from "@tanstack/react-query"
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
export interface UseVoiceMaterialsReturn {
voiceMaterials: AssetItem[]
loading: boolean
refetch: () => Promise<unknown>
}
export const useVoiceMaterials = (): UseVoiceMaterialsReturn => {
const query = useQuery({
queryKey: ["assets", "voice"],
queryFn: async () => {
const project = await getOrCreateDefaultProject()
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
const assets = await getAssetsByKind("voice")
return assets
},
staleTime: 30_000,
})
return {
voiceMaterials: query.data ?? [],
loading: query.isLoading,
refetch: query.refetch,
}
}
-550
View File
@@ -1,550 +0,0 @@
/**
* 片段(Clip)统一类型定义
* 片段 = 时间规划 + 类型标记,不绑定任何素材
*/
export type ClipType = "voice" | "pip"
/* ──────── 转场特效 ──────── */
/** 14 种转场类型 */
export type TransitionType =
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
/** 片段间转场配置 */
export interface TransitionConfig {
/** 转场类型 */
type: TransitionType
/** 转场时长(秒),0.3 ~ 2.0 */
duration: number
}
/** 默认转场配置 */
export const DEFAULT_TRANSITION: TransitionConfig = {
type: "none",
duration: 0.5,
}
/* ──────── 片段调速 ──────── */
/** 片段调速配置 */
export interface SpeedConfig {
/** 播放速度,0.25 ~ 4.0 */
rate: number
/** 音调修正(变速不变调) */
pitchCorrection: boolean
}
/** 默认调速配置 */
export const DEFAULT_SPEED: SpeedConfig = {
rate: 1.0,
pitchCorrection: true,
}
/* ──────── TTS 配音 ──────── */
/** 配音模式 */
export type TtsMode = "none" | "upload" | "tts"
/** TTS 配音配置 */
export interface TtsConfig {
/** 配音模式 */
mode: TtsMode
/** TTS 合成文本 */
text: string
/** 音色 ID */
voice_id: string
/** 语速 0.5 ~ 2.0 */
speed: number
/** 语调(半音)-12 ~ +12 */
pitch: number
/** 音量 0 ~ 100 */
volume: number
/** 字幕联动 */
subtitle_sync: boolean
}
/** 默认 TTS 配置 */
export const DEFAULT_TTS_CONFIG: TtsConfig = {
mode: "none",
text: "",
voice_id: "",
speed: 1.0,
pitch: 0,
volume: 100,
subtitle_sync: true,
}
/* ──────── 裁剪配置 ──────── */
/** 片段裁剪配置 — 定义素材的入点/出点 */
export interface TrimConfig {
/** 入点(秒),素材原始时间轴上的起始位置 */
start_time: number
/** 出点(秒),素材原始时间轴上的结束位置 */
end_time: number
/** 素材原始总时长(秒),用于"恢复原始长度" */
original_duration?: number
}
/* ──────── 水印配置 ──────── */
/** 水印类型 */
export type WatermarkType = "none" | "image" | "text" | "scroll"
/** 水印位置 */
export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"
/** 滚动水印方向 */
export type ScrollDirection = "horizontal" | "vertical" | "diagonal"
/** 水印配置 */
export interface WatermarkConfig {
/** 水印类型 */
type: WatermarkType
/** 图片水印 URL */
image_url?: string
/** 水印宽度(像素或百分比 0~1) */
width?: number
/** 水印高度(像素或百分比 0~1) */
height?: number
/** 水印位置 */
position: WatermarkPosition
/** 水印不透明度 0~1 */
opacity: number
/** 文字水印内容 */
text?: string
/** 文字水印字号 */
font_size?: number
/** 文字水印颜色 */
color?: string
/** 滚动水印方向 */
scroll_direction?: ScrollDirection
/** 滚动水印速度(像素/秒) */
scroll_speed?: number
}
/** 默认水印配置 */
export const DEFAULT_WATERMARK: WatermarkConfig = {
type: "none",
position: "bottom_right",
opacity: 0.7,
}
/* ──────── 片头片尾配置 ──────── */
/** 片头片尾素材类型 */
export type IntroOutroKind = "none" | "video" | "image"
/** 片头/片尾单项配置 */
export interface IntroOutroItem {
/** 素材类型 */
kind: IntroOutroKind
/** 素材 URL */
url?: string
/** 显示时长(秒) */
duration: number
/** 过渡动画 */
transition?: TransitionType
/** 过渡时长(秒) */
transition_duration?: number
}
/** 片头片尾完整配置 */
export interface IntroOutroConfig {
intro: IntroOutroItem
outro: IntroOutroItem
}
/** 默认片头片尾配置 */
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
intro: { kind: "none", duration: 3 },
outro: { kind: "none", duration: 3 },
}
/* ──────── 混剪配置 ──────── */
/** 九宫格位置 */
export type PipGridPosition =
| "top_left"
| "top_center"
| "top_right"
| "center_left"
| "center"
| "center_right"
| "bottom_left"
| "bottom_center"
| "bottom_right"
/** 入场动画类型 */
export type PipAnimType = "none" | "fade_in" | "slide_in"
/** 入场方向 */
export type PipSlideDirection = "left" | "right" | "up" | "down"
/** 混剪图层 */
export interface PipLayer {
id: string
/** 图层名称(用户可编辑) */
name: string
/** 素材类型 */
material_type: "image" | "video"
/** 素材 URL */
material_url: string
/** 素材缩略图 */
thumbnail_url?: string
/** 九宫格快捷位置 */
grid_position: PipGridPosition
/** 精确 X 坐标(百分比 0~100 */
x: number
/** 精确 Y 坐标(百分比 0~100 */
y: number
/** 宽度(百分比 0~100,相对主画面) */
width: number
/** 高度(百分比 0~100,相对主画面) */
height: number
/** 锁定宽高比 */
aspect_lock: boolean
/** 圆角(百分比 0~50 */
border_radius: number
/** 不透明度(0~100 */
opacity: number
/** 开始时间(秒) */
start_time: number
/** 持续时长(秒) */
duration: number
/** 入场动画 */
animation: PipAnimType
/** 入场方向 */
slide_direction: PipSlideDirection
/** 图层顺序(z-index */
z_index: number
}
/** 混剪配置 */
export interface PipConfig {
/** 是否启用混剪 */
enabled: boolean
/** 图层列表 */
layers: PipLayer[]
}
/** 默认 PiP 图层 */
export const DEFAULT_PIP_LAYER: PipLayer = {
id: "",
name: "图层",
material_type: "image",
material_url: "",
grid_position: "top_right",
x: 70,
y: 5,
width: 25,
height: 25,
aspect_lock: true,
border_radius: 0,
opacity: 100,
start_time: 0,
duration: 5,
animation: "none",
slide_direction: "right",
z_index: 1,
}
/** 默认 PiP 配置 */
export const DEFAULT_PIP_CONFIG: PipConfig = {
enabled: false,
layers: [],
}
/* ──────── 滤镜调色 ──────── */
/** 预设滤镜 */
export type FilterPreset =
| "none"
| "original"
| "fresh"
| "warm"
| "cool"
| "vintage"
| "cinema"
| "bw"
| "sunshine"
| "film"
/** 预设滤镜标签 */
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
none: "无",
original: "原片",
fresh: "清新",
warm: "暖调",
cool: "冷色",
vintage: "复古",
cinema: "电影",
bw: "黑白",
sunshine: "暖阳",
film: "胶片",
}
/** 滤镜调色配置 */
export interface FilterConfig {
/** 是否启用滤镜 */
enabled: boolean
/** 预设滤镜 */
preset: FilterPreset
/** 亮度(-100 ~ 100 */
brightness: number
/** 对比度(-100 ~ 100 */
contrast: number
/** 饱和度(-100 ~ 100 */
saturation: number
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
temperature: number
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
tint: number
/** 锐度(0 ~ 100 */
sharpness: number
}
/** 默认滤镜调色配置 */
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
enabled: false,
preset: "none",
brightness: 0,
contrast: 0,
saturation: 0,
temperature: 0,
tint: 0,
sharpness: 0,
}
/* ──────── 绿幕抠像 ──────── */
/** 绿幕抠像颜色预设 */
export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green"
/** 颜色预设标签 */
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
green: "绿",
blue: "蓝",
red: "红",
pure_green: "精绿",
soft_green: "柔绿",
}
/** 颜色预设对应的默认色值 */
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
green: "#00FF00",
blue: "#0000FF",
red: "#FF0000",
pure_green: "#00C800",
soft_green: "#40E040",
}
/** 绿幕抠像配置 */
export interface ChromaKeyConfig {
/** 是否启用绿幕抠像 */
enabled: boolean
/** 颜色预设 */
color_preset: ChromaKeyColorPreset
/** 抠像目标颜色(HEX */
color: string
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
similarity: number
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
blend: number
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
spill: number
}
/** 默认绿幕抠像配置 */
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
enabled: false,
color_preset: "green",
color: "#00FF00",
similarity: 30,
blend: 10,
spill: 20,
}
/* ──────── 贴纸配置 ──────── */
/** 贴纸类型 */
export type StickerType = "emoji" | "image" | "text"
/** 文字花字预设 */
export type TextStickerPreset =
| "normal" // 普通
| "highlight" // 高亮
| "bubble" // 气泡
| "neon" // 霓虹
| "shadow" // 投影
| "outline" // 描边
| "gradient" // 渐变
| "handwrite" // 手写
/** 贴纸项 */
export interface StickerItem {
id: string
/** 贴纸类型 */
type: StickerType
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
content: string
/** X 坐标(百分比 0~100 */
x: number
/** Y 坐标(百分比 0~100 */
y: number
/** 宽度(百分比 0~100 */
width: number
/** 高度(百分比 0~100 */
height: number
/** 旋转角度(度 -180~180 */
rotation: number
/** 不透明度(0~100 */
opacity: number
/** 开始时间(秒) */
start_time: number
/** 持续时长(秒,0 表示全程显示) */
duration: number
/** 图层顺序 */
z_index: number
/** 文字花字预设(仅 type=text 时有效) */
text_preset: TextStickerPreset
/** 文字颜色(仅 type=text 时有效) */
text_color: string
/** 文字大小(px,仅 type=text 时有效) */
font_size: number
}
/** 贴纸配置 */
export interface StickerConfig {
enabled: boolean
items: StickerItem[]
}
/** 默认贴纸项 */
export const DEFAULT_STICKER_ITEM: StickerItem = {
id: "",
type: "emoji",
content: "😀",
x: 50,
y: 50,
width: 15,
height: 15,
rotation: 0,
opacity: 100,
start_time: 0,
duration: 0,
z_index: 1,
text_preset: "normal",
text_color: "#FFFFFF",
font_size: 24,
}
/** 默认贴纸配置 */
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
enabled: false,
items: [],
}
/** 文字花字预设标签 */
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
normal: "普通",
highlight: "高亮",
bubble: "气泡",
neon: "霓虹",
shadow: "投影",
outline: "描边",
gradient: "渐变",
handwrite: "手写",
}
/* ──────── 封面配置 ──────── */
/** 封面来源模式 */
export type CoverMode = "auto" | "frame" | "upload"
/** 封面配置 */
export interface CoverConfig {
/** 是否启用自定义封面 */
enabled: boolean
/** 封面来源模式 */
mode: CoverMode
/** 抽帧时间点(秒,mode=frame 时使用) */
frame_time: number
/** 上传的封面 URLmode=upload 时使用) */
upload_url: string
/** AI 智能推荐的抽帧时间(由后端分析得出) */
ai_suggested_time: number | null
/** 封面缩略图 URL */
thumbnail_url: string
}
/** 默认封面配置 */
export const DEFAULT_COVER_CONFIG: CoverConfig = {
enabled: false,
mode: "auto",
frame_time: 0,
upload_url: "",
ai_suggested_time: null,
thumbnail_url: "",
}
/* ──────── 片段数据 ──────── */
export interface ClipData {
id: string
type: ClipType // 片段类型:voice(口播)或 pip(混剪)
duration: number // 时长(秒)
startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒)
/** 素材库素材 IDmain/pip 类型片段使用) */
media_asset_id?: string
// 保留兼容字段(后端序列化需要)
template_segment_id?: string
script_text?: string
order?: number
/** 配音素材 ID(voice 类型片段使用) */
voice_asset_id?: string
/** 配音素材文件 URL(voice 类型片段使用) */
voice_file_url?: string
/** 与前一片段之间的转场效果 */
transition?: TransitionConfig
/** 播放速度配置 */
speed?: SpeedConfig
/** TTS 配音配置 */
tts_config?: TtsConfig
/** 裁剪配置 — 定义素材入点/出点 */
trim_config?: TrimConfig
}
/* ──────── 标题设置 ──────── */
/**
* 标题设置 — 对齐后端 title_config 字段
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
*/
export interface TitleSettings {
aiAutoSelect: boolean
title: string
position: string
font: string
size: number
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
color: string
}
@@ -0,0 +1,50 @@
/**
* 绿幕抠像类型
*/
/** 绿幕抠像颜色预设 */
export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green"
/** 颜色预设标签 */
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
green: "绿",
blue: "蓝",
red: "红",
pure_green: "精绿",
soft_green: "柔绿",
}
/** 颜色预设对应的默认色值 */
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
green: "#00FF00",
blue: "#0000FF",
red: "#FF0000",
pure_green: "#00C800",
soft_green: "#40E040",
}
/** 绿幕抠像配置 */
export interface ChromaKeyConfig {
/** 是否启用绿幕抠像 */
enabled: boolean
/** 颜色预设 */
color_preset: ChromaKeyColorPreset
/** 抠像目标颜色(HEX */
color: string
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
similarity: number
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
blend: number
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
spill: number
}
/** 默认绿幕抠像配置 */
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
enabled: false,
color_preset: "green",
color: "#00FF00",
similarity: 30,
blend: 10,
spill: 20,
}
@@ -0,0 +1,36 @@
/**
* 片段数据类型
*/
import type { TransitionConfig } from "./transition"
import type { SpeedConfig } from "./speed"
import type { TtsConfig } from "./tts"
import type { TrimConfig } from "./trim"
/** 片段类型 */
export type ClipType = "voice" | "pip"
/** 片段数据 — 时间规划 + 类型标记,不绑定任何素材 */
export interface ClipData {
id: string
type: ClipType // 片段类型:voice(口播)或 pip(混剪)
duration: number // 时长(秒)
startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒)
/** 素材库素材 IDmain/pip 类型片段使用) */
media_asset_id?: string
// 保留兼容字段(后端序列化需要)
template_segment_id?: string
script_text?: string
order?: number
/** 配音素材 ID(voice 类型片段使用) */
voice_asset_id?: string
/** 配音素材文件 URL(voice 类型片段使用) */
voice_file_url?: string
/** 与前一片段之间的转场效果 */
transition?: TransitionConfig
/** 播放速度配置 */
speed?: SpeedConfig
/** TTS 配音配置 */
tts_config?: TtsConfig
/** 裁剪配置 — 定义素材入点/出点 */
trim_config?: TrimConfig
}
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* ClipPropertiesPanel 相关类型定义 * ClipPropertiesPanel 相关类型定义
*/ */
import type { ClipData } from "@/pages/editing-planner/types" import type { ClipData } from "./clip"
import type { TemplateMode } from "@/api/editing-planner" import type { TemplateMode } from "@/api/editing-planner"
import type { AssetItem } from "@/api/assets" import type { AssetItem } from "@/api/assets"
@@ -0,0 +1,32 @@
/**
* 封面配置类型
*/
/** 封面来源模式 */
export type CoverMode = "auto" | "frame" | "upload"
/** 封面配置 */
export interface CoverConfig {
/** 是否启用自定义封面 */
enabled: boolean
/** 封面来源模式 */
mode: CoverMode
/** 抽帧时间点(秒,mode=frame 时使用) */
frame_time: number
/** 上传的封面 URLmode=upload 时使用) */
upload_url: string
/** AI 智能推荐的抽帧时间(由后端分析得出) */
ai_suggested_time: number | null
/** 封面缩略图 URL */
thumbnail_url: string
}
/** 默认封面配置 */
export const DEFAULT_COVER_CONFIG: CoverConfig = {
enabled: false,
mode: "auto",
frame_time: 0,
upload_url: "",
ai_suggested_time: null,
thumbnail_url: "",
}
@@ -0,0 +1,62 @@
/**
* 滤镜调色类型
*/
/** 预设滤镜 */
export type FilterPreset =
| "none"
| "original"
| "fresh"
| "warm"
| "cool"
| "vintage"
| "cinema"
| "bw"
| "sunshine"
| "film"
/** 预设滤镜标签 */
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
none: "无",
original: "原片",
fresh: "清新",
warm: "暖调",
cool: "冷色",
vintage: "复古",
cinema: "电影",
bw: "黑白",
sunshine: "暖阳",
film: "胶片",
}
/** 滤镜调色配置 */
export interface FilterConfig {
/** 是否启用滤镜 */
enabled: boolean
/** 预设滤镜 */
preset: FilterPreset
/** 亮度(-100 ~ 100 */
brightness: number
/** 对比度(-100 ~ 100 */
contrast: number
/** 饱和度(-100 ~ 100 */
saturation: number
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
temperature: number
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
tint: number
/** 锐度(0 ~ 100 */
sharpness: number
}
/** 默认滤镜调色配置 */
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
enabled: false,
preset: "none",
brightness: 0,
contrast: 0,
saturation: 0,
temperature: 0,
tint: 0,
sharpness: 0,
}
@@ -0,0 +1,84 @@
/**
* EditingPlanner 类型定义入口
* 按功能模块拆分,统一从这里导出
*/
/* 转场 */
export { type TransitionType, type TransitionConfig, DEFAULT_TRANSITION } from "./transition"
/* 调速 */
export { type SpeedConfig, DEFAULT_SPEED } from "./speed"
/* TTS 配音 */
export { type TtsMode, type TtsConfig, DEFAULT_TTS_CONFIG } from "./tts"
/* 裁剪 */
export { type TrimConfig } from "./trim"
/* 水印 */
export {
type WatermarkType,
type WatermarkPosition,
type ScrollDirection,
type WatermarkConfig,
DEFAULT_WATERMARK,
} from "./watermark"
/* 片头片尾 */
export {
type IntroOutroKind,
type IntroOutroItem,
type IntroOutroConfig,
DEFAULT_INTRO_OUTRO,
} from "./intro-outro"
/* 混剪 PiP */
export {
type PipGridPosition,
type PipAnimType,
type PipSlideDirection,
type PipLayer,
type PipConfig,
DEFAULT_PIP_LAYER,
DEFAULT_PIP_CONFIG,
} from "./pip"
/* 滤镜调色 */
export {
type FilterPreset,
FILTER_PRESET_LABELS,
type FilterConfig,
DEFAULT_FILTER_CONFIG,
} from "./filter"
/* 绿幕抠像 */
export {
type ChromaKeyColorPreset,
CHROMA_KEY_PRESET_LABELS,
CHROMA_KEY_PRESET_COLORS,
type ChromaKeyConfig,
DEFAULT_CHROMA_KEY_CONFIG,
} from "./chroma-key"
/* 贴纸 */
export {
type StickerType,
type TextStickerPreset,
type StickerItem,
type StickerConfig,
DEFAULT_STICKER_ITEM,
DEFAULT_STICKER_CONFIG,
TEXT_STICKER_PRESET_LABELS,
} from "./sticker"
/* 封面 */
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG } from "./cover"
/* 片段数据 */
export { type ClipType, type ClipData } from "./clip"
/* 标题设置 */
export { type TitleSettings } from "./title"
/* 字幕样式 */
export { type SubtitleStyleConfig, DEFAULT_SUBTITLE_STYLE } from "./subtitle"
@@ -0,0 +1,33 @@
/**
* 片头片尾配置类型
*/
import type { TransitionType } from "./transition"
/** 片头片尾素材类型 */
export type IntroOutroKind = "none" | "video" | "image"
/** 片头/片尾单项配置 */
export interface IntroOutroItem {
/** 素材类型 */
kind: IntroOutroKind
/** 素材 URL */
url?: string
/** 显示时长(秒) */
duration: number
/** 过渡动画 */
transition?: TransitionType
/** 过渡时长(秒) */
transition_duration?: number
}
/** 片头片尾完整配置 */
export interface IntroOutroConfig {
intro: IntroOutroItem
outro: IntroOutroItem
}
/** 默认片头片尾配置 */
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
intro: { kind: "none", duration: 3 },
outro: { kind: "none", duration: 3 },
}
@@ -0,0 +1,95 @@
/**
* 混剪(PiP)配置类型
*/
/** 九宫格位置 */
export type PipGridPosition =
| "top_left"
| "top_center"
| "top_right"
| "center_left"
| "center"
| "center_right"
| "bottom_left"
| "bottom_center"
| "bottom_right"
/** 入场动画类型 */
export type PipAnimType = "none" | "fade_in" | "slide_in"
/** 入场方向 */
export type PipSlideDirection = "left" | "right" | "up" | "down"
/** 混剪图层 */
export interface PipLayer {
id: string
/** 图层名称(用户可编辑) */
name: string
/** 素材类型 */
material_type: "image" | "video"
/** 素材 URL */
material_url: string
/** 素材缩略图 */
thumbnail_url?: string
/** 九宫格快捷位置 */
grid_position: PipGridPosition
/** 精确 X 坐标(百分比 0~100 */
x: number
/** 精确 Y 坐标(百分比 0~100 */
y: number
/** 宽度(百分比 0~100,相对主画面) */
width: number
/** 高度(百分比 0~100,相对主画面) */
height: number
/** 锁定宽高比 */
aspect_lock: boolean
/** 圆角(百分比 0~50 */
border_radius: number
/** 不透明度(0~100 */
opacity: number
/** 开始时间(秒) */
start_time: number
/** 持续时长(秒) */
duration: number
/** 入场动画 */
animation: PipAnimType
/** 入场方向 */
slide_direction: PipSlideDirection
/** 图层顺序(z-index */
z_index: number
}
/** 混剪配置 */
export interface PipConfig {
/** 是否启用混剪 */
enabled: boolean
/** 图层列表 */
layers: PipLayer[]
}
/** 默认 PiP 图层 */
export const DEFAULT_PIP_LAYER: PipLayer = {
id: "",
name: "图层",
material_type: "image",
material_url: "",
grid_position: "top_right",
x: 70,
y: 5,
width: 25,
height: 25,
aspect_lock: true,
border_radius: 0,
opacity: 100,
start_time: 0,
duration: 5,
animation: "none",
slide_direction: "right",
z_index: 1,
}
/** 默认 PiP 配置 */
export const DEFAULT_PIP_CONFIG: PipConfig = {
enabled: false,
layers: [],
}
@@ -0,0 +1,17 @@
/**
* 片段调速类型
*/
/** 片段调速配置 */
export interface SpeedConfig {
/** 播放速度,0.25 ~ 4.0 */
rate: number
/** 音调修正(变速不变调) */
pitchCorrection: boolean
}
/** 默认调速配置 */
export const DEFAULT_SPEED: SpeedConfig = {
rate: 1.0,
pitchCorrection: true,
}
@@ -0,0 +1,93 @@
/**
* 贴纸配置类型
*/
/** 贴纸类型 */
export type StickerType = "emoji" | "image" | "text"
/** 文字花字预设 */
export type TextStickerPreset =
| "normal" // 普通
| "highlight" // 高亮
| "bubble" // 气泡
| "neon" // 霓虹
| "shadow" // 投影
| "outline" // 描边
| "gradient" // 渐变
| "handwrite" // 手写
/** 贴纸项 */
export interface StickerItem {
id: string
/** 贴纸类型 */
type: StickerType
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
content: string
/** X 坐标(百分比 0~100 */
x: number
/** Y 坐标(百分比 0~100 */
y: number
/** 宽度(百分比 0~100 */
width: number
/** 高度(百分比 0~100 */
height: number
/** 旋转角度(度 -180~180 */
rotation: number
/** 不透明度(0~100 */
opacity: number
/** 开始时间(秒) */
start_time: number
/** 持续时长(秒,0 表示全程显示) */
duration: number
/** 图层顺序 */
z_index: number
/** 文字花字预设(仅 type=text 时有效) */
text_preset: TextStickerPreset
/** 文字颜色(仅 type=text 时有效) */
text_color: string
/** 文字大小(px,仅 type=text 时有效) */
font_size: number
}
/** 贴纸配置 */
export interface StickerConfig {
enabled: boolean
items: StickerItem[]
}
/** 默认贴纸项 */
export const DEFAULT_STICKER_ITEM: StickerItem = {
id: "",
type: "emoji",
content: "😀",
x: 50,
y: 50,
width: 15,
height: 15,
rotation: 0,
opacity: 100,
start_time: 0,
duration: 0,
z_index: 1,
text_preset: "normal",
text_color: "#FFFFFF",
font_size: 24,
}
/** 默认贴纸配置 */
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
enabled: false,
items: [],
}
/** 文字花字预设标签 */
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
normal: "普通",
highlight: "高亮",
bubble: "气泡",
neon: "霓虹",
shadow: "投影",
outline: "描边",
gradient: "渐变",
handwrite: "手写",
}
@@ -0,0 +1,20 @@
/**
* 标题设置类型
*/
/**
* 标题设置 — 对齐后端 title_config 字段
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
*/
export interface TitleSettings {
aiAutoSelect: boolean
title: string
position: string
font: string
size: number
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
color: string
}
@@ -0,0 +1,35 @@
/**
* 转场特效类型
*/
/** 14 种转场类型 */
export type TransitionType =
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
/** 片段间转场配置 */
export interface TransitionConfig {
/** 转场类型 */
type: TransitionType
/** 转场时长(秒),0.3 ~ 2.0 */
duration: number
}
/** 默认转场配置 */
export const DEFAULT_TRANSITION: TransitionConfig = {
type: "none",
duration: 0.5,
}
@@ -0,0 +1,13 @@
/**
* 片段裁剪类型
*/
/** 片段裁剪配置 — 定义素材的入点/出点 */
export interface TrimConfig {
/** 入点(秒),素材原始时间轴上的起始位置 */
start_time: number
/** 出点(秒),素材原始时间轴上的结束位置 */
end_time: number
/** 素材原始总时长(秒),用于"恢复原始长度" */
original_duration?: number
}
@@ -0,0 +1,35 @@
/**
* TTS 配音类型
*/
/** 配音模式 */
export type TtsMode = "none" | "upload" | "tts"
/** TTS 配音配置 */
export interface TtsConfig {
/** 配音模式 */
mode: TtsMode
/** TTS 合成文本 */
text: string
/** 音色 ID */
voice_id: string
/** 语速 0.5 ~ 2.0 */
speed: number
/** 语调(半音)-12 ~ +12 */
pitch: number
/** 音量 0 ~ 100 */
volume: number
/** 字幕联动 */
subtitle_sync: boolean
}
/** 默认 TTS 配置 */
export const DEFAULT_TTS_CONFIG: TtsConfig = {
mode: "none",
text: "",
voice_id: "",
speed: 1.0,
pitch: 0,
volume: 100,
subtitle_sync: true,
}
@@ -0,0 +1,45 @@
/**
* 水印配置类型
*/
/** 水印类型 */
export type WatermarkType = "none" | "image" | "text" | "scroll"
/** 水印位置 */
export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"
/** 滚动水印方向 */
export type ScrollDirection = "horizontal" | "vertical" | "diagonal"
/** 水印配置 */
export interface WatermarkConfig {
/** 水印类型 */
type: WatermarkType
/** 图片水印 URL */
image_url?: string
/** 水印宽度(像素或百分比 0~1) */
width?: number
/** 水印高度(像素或百分比 0~1) */
height?: number
/** 水印位置 */
position: WatermarkPosition
/** 水印不透明度 0~1 */
opacity: number
/** 文字水印内容 */
text?: string
/** 文字水印字号 */
font_size?: number
/** 文字水印颜色 */
color?: string
/** 滚动水印方向 */
scroll_direction?: ScrollDirection
/** 滚动水印速度(像素/秒) */
scroll_speed?: number
}
/** 默认水印配置 */
export const DEFAULT_WATERMARK: WatermarkConfig = {
type: "none",
position: "bottom_right",
opacity: 0.7,
}
@@ -0,0 +1,113 @@
import { useState, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import { message } from "antd"
import { useQuery, useMutation } from "@tanstack/react-query"
import { saveTtsToLibrary } from "@/api/tts"
import { getTags, createTag } from "@/api/tags"
/**
* 存为素材(配音库)弹窗逻辑
*/
export function useSaveToLibrary(completedTtsJobId: string | null, resetTtsState: () => void) {
const navigate = useNavigate()
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [saveName, setSaveName] = useState("")
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
const [saveNewTag, setSaveNewTag] = useState("")
const { data: allTags = [] } = useQuery({
queryKey: ["generate-save-tags"],
queryFn: getTags,
staleTime: 30_000,
})
const handleGoToLibrary = useCallback(() => {
navigate("/app/voice-materials")
}, [navigate])
const saveToLibraryMutation = useMutation({
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
saveTtsToLibrary(completedTtsJobId!, params),
onSuccess: () => {
message.success({
content: (
<span>
{" "}
<a
onClick={handleGoToLibrary}
style={{
color: "var(--primary-500, #6366f1)",
cursor: "pointer",
}}
>
</a>
</span>
),
duration: 5,
})
setSaveModalOpen(false)
setSaveName("")
setSaveTagIds([])
setSaveNewTag("")
resetTtsState()
},
onError: (err: Error) => {
message.error(`保存失败:${err.message || "请重试"}`)
},
})
const handleOpenSaveModal = useCallback(() => {
setSaveName("")
setSaveTagIds([])
setSaveNewTag("")
setSaveModalOpen(true)
}, [])
const handleConfirmSave = useCallback(() => {
if (!completedTtsJobId) return
saveToLibraryMutation.mutate({
name: saveName.trim() || undefined,
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
})
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
const handleAddTagInModal = useCallback(
async (tagName: string) => {
const trimmed = tagName.trim()
if (!trimmed) return
const existing = allTags.find((t) => t.name === trimmed)
if (existing) {
if (!saveTagIds.includes(existing.id)) {
setSaveTagIds((prev) => [...prev, existing.id])
}
return
}
try {
const created = await createTag(trimmed)
setSaveTagIds((prev) => [...prev, created.id])
setSaveNewTag("")
} catch {
message.error(`创建标签"${trimmed}"失败`)
}
},
[allTags, saveTagIds],
)
return {
saveModalOpen,
setSaveModalOpen,
saveName,
setSaveName,
saveTagIds,
setSaveTagIds,
saveNewTag,
setSaveNewTag,
allTags,
saveToLibraryMutation,
handleOpenSaveModal,
handleConfirmSave,
handleAddTagInModal,
}
}
@@ -0,0 +1,96 @@
import { useState, useCallback, useEffect } from "react"
import { message } from "antd"
import { useMutation } from "@tanstack/react-query"
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts"
/**
* TTS 自定义合成 + 轮询状态
*/
export function useTtsSynthesis(selectedVoice: string) {
const [customVoiceText, setCustomVoiceText] = useState("")
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
const [ttsError, setTtsError] = useState<string | null>(null)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
const synthesizeMutation = useMutation({
mutationFn: synthesizeSpeech,
onSuccess: (data) => {
setTtsJobId(data.job_id)
message.info("语音合成已提交,等待处理…")
},
onError: () => {
setTtsError("语音合成请求失败,请重试")
},
})
/* 轮询 TTS 任务状态 */
useEffect(() => {
if (!ttsJobId) return
let cancelled = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
try {
const status = await getTTSJobStatus(ttsJobId)
if (cancelled) return
if (status.status === "completed") {
setCustomAudioUrl(status.output_audio_url)
setCompletedTtsJobId(ttsJobId)
setTtsJobId(null)
setTtsError(null)
message.success("语音合成完成!")
return
}
if (status.status === "failed" || status.status === "cancelled") {
setTtsError(status.error_message || "语音合成失败")
setTtsJobId(null)
return
}
timer = setTimeout(poll, 2000)
} catch {
if (!cancelled) {
setTtsError("查询合成状态失败")
setTtsJobId(null)
}
}
}
timer = setTimeout(poll, 2000)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [ttsJobId])
const handleSynthesizeVoice = useCallback(() => {
if (!customVoiceText.trim()) {
message.warning("请先输入配音文案")
return
}
setTtsError(null)
setCustomAudioUrl(null)
synthesizeMutation.mutate({
text: customVoiceText.trim(),
voice_id: selectedVoice || undefined,
language: "zh-CN",
})
}, [customVoiceText, selectedVoice, synthesizeMutation])
const resetTtsState = useCallback(() => {
setCompletedTtsJobId(null)
setCustomAudioUrl(null)
}, [])
return {
customVoiceText,
setCustomVoiceText,
customAudioUrl,
ttsError,
ttsJobId,
completedTtsJobId,
synthesizeMutation,
handleSynthesizeVoice,
resetTtsState,
}
}
@@ -0,0 +1,39 @@
import { useState, useRef, useCallback } from "react"
import { message } from "antd"
/**
* 音色试听播放控制
*/
export function useVoiceAudio() {
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
const toggleVoicePlay = useCallback(
(voiceId: string, previewUrl: string | null) => {
if (playingVoice === voiceId) {
audioRef.current?.pause()
audioRef.current = null
setPlayingVoice(null)
return
}
audioRef.current?.pause()
if (!previewUrl) {
message.warning("该音色暂无试听音频")
return
}
const audio = new Audio(previewUrl)
audioRef.current = audio
audio.play().catch(() => {
message.error("播放失败,请检查网络")
})
audio.onended = () => {
setPlayingVoice(null)
audioRef.current = null
}
setPlayingVoice(voiceId)
},
[playingVoice],
)
return { playingVoice, toggleVoicePlay }
}
@@ -0,0 +1,68 @@
import { useState, useCallback, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { fetchPresetVoices } from "@/api/voices"
import type { PresetVoiceItem } from "@/api/voices"
/**
* 智能配音推荐
* 根据标题内容风格模拟推荐音色
*/
export function useVoiceRecommend(titleText: string) {
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
const handleVoiceRecommend = useCallback(async () => {
if (presetVoices.length === 0) return
setVoiceRecommendLoading(true)
setHasVoiceRecommend(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
const title = titleText.toLowerCase()
let recommended: string[] = []
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
recommended = femaleVoices.slice(0, 3)
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
} else {
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
}
if (recommended.length < 3) {
const others = presetVoices
.filter((v) => !recommended.includes(v.voice_id))
.map((v) => v.voice_id)
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
}
setVoiceRecommendations(recommended)
setVoiceRecommendLoading(false)
}, [presetVoices, titleText])
return {
presetVoices,
presetVoicesLoading,
voiceRecommendLoading,
voiceRecommendations,
hasVoiceRecommend,
handleVoiceRecommend,
}
}
@@ -1,251 +0,0 @@
/**
* Step 4 标题设置 Hook
* 封装 AI 标题生成、标题样式设置等逻辑
*/
import { useState, useCallback, useMemo } from "react"
import { message } from "antd"
import { useQuery } from "@tanstack/react-query"
import { getTitles } from "@/api/titles"
import { TITLE_PRESETS, AI_TITLE_TEMPLATES } from "../constants"
import type { TitleSettings } from "../types"
interface UseStep4TitleProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
}
interface AiTitleItem {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
}
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
/* ── 标题库 API ── */
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: () => getTitles(),
staleTime: 30_000,
})
/* ── AI 标题生成状态 ── */
const [aiTitleInput, setAiTitleInput] = useState("")
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
/* ── 辅助函数 ── */
const extractTopic = (text: string): string => {
const keywords = text
.replace(/[,。!?、,.!?]/g, " ")
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return "这个话题"
// 取前3个关键词组合
return keywords.slice(0, 3).join("")
}
const getActivePreset = (settings: TitleSettings): string | null => {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
/* ── AI 标题生成 ── */
const handleGenerateAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) {
message.warning("请先输入视频描述或关键词")
return
}
setAiTitleGenerating(true)
setHasGeneratedTitles(true)
// 模拟 AI 生成延迟
await new Promise((resolve) => setTimeout(resolve, 1200))
const topic = extractTopic(aiTitleInput)
const results: AiTitleItem[] = []
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style]
// 每种风格随机选2个
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
shuffled.forEach((tpl) => {
const title = tpl.replace(/\{topic\}/g, topic)
const highlights = {
catchy: "吸睛标题",
emotional: "情感共鸣",
informative: "知识干货",
}
results.push({
title,
highlight: highlights[style],
style,
})
})
})
// 打乱顺序
results.sort(() => Math.random() - 0.5)
setAiTitleResults(results)
setAiTitleGenerating(false)
}, [aiTitleInput])
const handleSelectAiTitle = useCallback(
(title: string) => {
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
message.success("已选用此标题")
},
[titleSettings, onTitleSettingsChange],
)
const handleRefreshAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) return
setAiTitleGenerating(true)
await new Promise((resolve) => setTimeout(resolve, 800))
// 重新生成一批
const topic = extractTopic(aiTitleInput)
const results: AiTitleItem[] = []
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style]
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
shuffled.forEach((tpl) => {
results.push({
title: tpl.replace(/\{topic\}/g, topic),
highlight: highlights[style],
style,
})
})
})
results.sort(() => Math.random() - 0.5)
setAiTitleResults(results)
setAiTitleGenerating(false)
}, [aiTitleInput])
/* ── 标题设置更新 ── */
const updateTitle = useCallback(
(title: string) => {
onTitleSettingsChange({ ...titleSettings, title })
},
[titleSettings, onTitleSettingsChange],
)
const toggleAiAutoSelect = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
}, [titleSettings, onTitleSettingsChange])
const updatePosition = useCallback(
(position: string) => {
onTitleSettingsChange({ ...titleSettings, position })
},
[titleSettings, onTitleSettingsChange],
)
const updateFont = useCallback(
(font: string) => {
onTitleSettingsChange({ ...titleSettings, font })
},
[titleSettings, onTitleSettingsChange],
)
const updateSize = useCallback(
(size: number) => {
onTitleSettingsChange({ ...titleSettings, size })
},
[titleSettings, onTitleSettingsChange],
)
const updateColor = useCallback(
(color: string) => {
onTitleSettingsChange({ ...titleSettings, color })
},
[titleSettings, onTitleSettingsChange],
)
const toggleBold = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
}, [titleSettings, onTitleSettingsChange])
const toggleItalic = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
}, [titleSettings, onTitleSettingsChange])
const toggleStroke = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
}, [titleSettings, onTitleSettingsChange])
const toggleShadow = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
}, [titleSettings, onTitleSettingsChange])
const applyPreset = useCallback(
(presetKey: string) => {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
onTitleSettingsChange({
...titleSettings,
size: preset.style.size,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
})
},
[titleSettings, onTitleSettingsChange],
)
return {
// 数据
userTitles,
titleSettings,
aiTitleInput,
setAiTitleInput,
aiTitleGenerating,
aiTitleResults,
hasGeneratedTitles,
activePreset,
titlePresets: TITLE_PRESETS,
// AI 标题操作
handleGenerateAiTitles,
handleSelectAiTitle,
handleRefreshAiTitles,
// 标题设置操作
updateTitle,
toggleAiAutoSelect,
updatePosition,
updateFont,
updateSize,
updateColor,
toggleBold,
toggleItalic,
toggleStroke,
toggleShadow,
applyPreset,
}
}
export default useStep4Title
@@ -0,0 +1,61 @@
import { useQuery } from "@tanstack/react-query"
import { getTitles } from "@/api/titles"
import type { TitleSettings } from "../../types"
import { useAiTitleGenerator } from "./useAiTitleGenerator"
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
interface UseStep4TitleProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
}
/**
* Step 4 标题设置 Hook
* 封装 AI 标题生成、标题样式设置等逻辑
*/
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
// 标题库数据
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: () => getTitles(),
staleTime: 30_000,
})
// AI 标题生成
const aiGenerator = useAiTitleGenerator({ titleSettings, onTitleSettingsChange })
// 样式更新
const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange })
return {
// 数据
userTitles,
titleSettings,
// AI 标题状态
aiTitleInput: aiGenerator.aiTitleInput,
setAiTitleInput: aiGenerator.setAiTitleInput,
aiTitleGenerating: aiGenerator.aiTitleGenerating,
aiTitleResults: aiGenerator.aiTitleResults,
hasGeneratedTitles: aiGenerator.hasGeneratedTitles,
activePreset: styleUpdaters.activePreset,
titlePresets: styleUpdaters.titlePresets,
// AI 标题操作
handleGenerateAiTitles: aiGenerator.handleGenerateAiTitles,
handleSelectAiTitle: aiGenerator.handleSelectAiTitle,
handleRefreshAiTitles: aiGenerator.handleRefreshAiTitles,
// 标题设置操作
updateTitle: styleUpdaters.updateTitle,
toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect,
updatePosition: styleUpdaters.updatePosition,
updateFont: styleUpdaters.updateFont,
updateSize: styleUpdaters.updateSize,
updateColor: styleUpdaters.updateColor,
toggleBold: styleUpdaters.toggleBold,
toggleItalic: styleUpdaters.toggleItalic,
toggleStroke: styleUpdaters.toggleStroke,
toggleShadow: styleUpdaters.toggleShadow,
applyPreset: styleUpdaters.applyPreset,
}
}
export default useStep4Title
@@ -0,0 +1,102 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { AI_TITLE_TEMPLATES } from "../../constants"
import type { TitleSettings } from "../../types"
export interface AiTitleItem {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
}
interface UseAiTitleGeneratorOptions {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
}
/**
* AI 标题生成 Hook
* 封装 AI 标题生成、刷新、选择等逻辑
*/
export function useAiTitleGenerator({
titleSettings,
onTitleSettingsChange,
}: UseAiTitleGeneratorOptions) {
const [aiTitleInput, setAiTitleInput] = useState("")
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
const extractTopic = (text: string): string => {
const keywords = text
.replace(/[,。!?、,.!?]/g, " ")
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return "这个话题"
return keywords.slice(0, 3).join("")
}
const generateTitlesFromTopic = (topic: string): AiTitleItem[] => {
const results: AiTitleItem[] = []
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style]
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
shuffled.forEach((tpl) => {
results.push({
title: tpl.replace(/\{topic\}/g, topic),
highlight: highlights[style],
style,
})
})
})
results.sort(() => Math.random() - 0.5)
return results
}
const handleGenerateAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) {
message.warning("请先输入视频描述或关键词")
return
}
setAiTitleGenerating(true)
setHasGeneratedTitles(true)
await new Promise((resolve) => setTimeout(resolve, 1200))
const topic = extractTopic(aiTitleInput)
setAiTitleResults(generateTitlesFromTopic(topic))
setAiTitleGenerating(false)
}, [aiTitleInput])
const handleSelectAiTitle = useCallback(
(title: string) => {
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
message.success("已选用此标题")
},
[titleSettings, onTitleSettingsChange],
)
const handleRefreshAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) return
setAiTitleGenerating(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const topic = extractTopic(aiTitleInput)
setAiTitleResults(generateTitlesFromTopic(topic))
setAiTitleGenerating(false)
}, [aiTitleInput])
return {
aiTitleInput,
setAiTitleInput,
aiTitleGenerating,
aiTitleResults,
hasGeneratedTitles,
handleGenerateAiTitles,
handleSelectAiTitle,
handleRefreshAiTitles,
}
}
@@ -0,0 +1,123 @@
import { useCallback, useMemo } from "react"
import { TITLE_PRESETS } from "../../constants"
import type { TitleSettings } from "../../types"
interface UseTitleStyleUpdatersOptions {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
}
/**
* 标题样式更新 Hook
* 封装标题文字、位置、字体、样式等所有设置更新函数
*/
export function useTitleStyleUpdaters({
titleSettings,
onTitleSettingsChange,
}: UseTitleStyleUpdatersOptions) {
const getActivePreset = (settings: TitleSettings): string | null => {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
const updateTitle = useCallback(
(title: string) => {
onTitleSettingsChange({ ...titleSettings, title })
},
[titleSettings, onTitleSettingsChange],
)
const toggleAiAutoSelect = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
}, [titleSettings, onTitleSettingsChange])
const updatePosition = useCallback(
(position: string) => {
onTitleSettingsChange({ ...titleSettings, position })
},
[titleSettings, onTitleSettingsChange],
)
const updateFont = useCallback(
(font: string) => {
onTitleSettingsChange({ ...titleSettings, font })
},
[titleSettings, onTitleSettingsChange],
)
const updateSize = useCallback(
(size: number) => {
onTitleSettingsChange({ ...titleSettings, size })
},
[titleSettings, onTitleSettingsChange],
)
const updateColor = useCallback(
(color: string) => {
onTitleSettingsChange({ ...titleSettings, color })
},
[titleSettings, onTitleSettingsChange],
)
const toggleBold = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
}, [titleSettings, onTitleSettingsChange])
const toggleItalic = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
}, [titleSettings, onTitleSettingsChange])
const toggleStroke = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
}, [titleSettings, onTitleSettingsChange])
const toggleShadow = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
}, [titleSettings, onTitleSettingsChange])
const applyPreset = useCallback(
(presetKey: string) => {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
onTitleSettingsChange({
...titleSettings,
size: preset.style.size,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
})
},
[titleSettings, onTitleSettingsChange],
)
return {
activePreset,
titlePresets: TITLE_PRESETS,
updateTitle,
toggleAiAutoSelect,
updatePosition,
updateFont,
updateSize,
updateColor,
toggleBold,
toggleItalic,
toggleStroke,
toggleShadow,
applyPreset,
}
}
@@ -2,17 +2,15 @@
* Step 5 配音选择 Hook * Step 5 配音选择 Hook
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
*/ */
import { useState, useRef, useCallback, useEffect, useMemo } from "react" import { useCallback } from "react"
import { useNavigate } from "react-router-dom"
import { message } from "antd"
import { useQuery, useMutation } from "@tanstack/react-query"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { getTags, createTag } from "@/api/tags"
import { formatDuration } from "../utils/formatDuration"
import type { VoiceClone } from "@/api/voice-clone" import type { VoiceClone } from "@/api/voice-clone"
import { message } from "antd"
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
import { formatDuration } from "../utils/formatDuration"
import { useVoiceAudio } from "./step5-voice/useVoiceAudio"
import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"
import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"
import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"
interface UseStep5VoiceProps { interface UseStep5VoiceProps {
selectedVoice: string selectedVoice: string
@@ -43,91 +41,47 @@ export function useStep5Voice({
onCloneModalOpenChange, onCloneModalOpenChange,
titleText, titleText,
}: UseStep5VoiceProps) { }: UseStep5VoiceProps) {
const navigate = useNavigate() /* ── 子模块 ── */
/* ── 预置音色 API ── */ const { playingVoice, toggleVoicePlay } = useVoiceAudio()
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── 音频播放 ── */ const {
const audioRef = useRef<HTMLAudioElement | null>(null) presetVoices,
const [playingVoice, setPlayingVoice] = useState<string | null>(null) presetVoicesLoading,
voiceRecommendLoading,
voiceRecommendations,
hasVoiceRecommend,
handleVoiceRecommend,
} = useVoiceRecommend(titleText)
const toggleVoicePlay = useCallback( const {
(voiceId: string, previewUrl: string | null) => { customVoiceText,
if (playingVoice === voiceId) { setCustomVoiceText,
audioRef.current?.pause() customAudioUrl,
audioRef.current = null ttsError,
setPlayingVoice(null) ttsJobId,
return completedTtsJobId,
} synthesizeMutation,
audioRef.current?.pause() handleSynthesizeVoice,
if (!previewUrl) { resetTtsState,
message.warning("该音色暂无试听音频") } = useTtsSynthesis(selectedVoice)
return
}
const audio = new Audio(previewUrl)
audioRef.current = audio
audio.play().catch(() => {
message.error("播放失败,请检查网络")
})
audio.onended = () => {
setPlayingVoice(null)
audioRef.current = null
}
setPlayingVoice(voiceId)
},
[playingVoice],
)
/* ── 智能配音推荐 ── */ const {
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) saveModalOpen,
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([]) setSaveModalOpen,
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) saveName,
setSaveName,
const handleVoiceRecommend = useCallback(async () => { saveTagIds,
if (presetVoices.length === 0) return setSaveTagIds,
setVoiceRecommendLoading(true) saveNewTag,
setHasVoiceRecommend(true) setSaveNewTag,
allTags,
await new Promise((resolve) => setTimeout(resolve, 1000)) saveToLibraryMutation,
handleOpenSaveModal,
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年 handleConfirmSave,
const title = titleText.toLowerCase() handleAddTagInModal,
let recommended: string[] = [] } = useSaveToLibrary(completedTtsJobId, resetTtsState)
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
recommended = femaleVoices.slice(0, 3)
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
} else {
// 默认推荐前3个
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
}
// 不足3个时补足
if (recommended.length < 3) {
const others = presetVoices
.filter((v) => !recommended.includes(v.voice_id))
.map((v) => v.voice_id)
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
}
setVoiceRecommendations(recommended)
setVoiceRecommendLoading(false)
}, [presetVoices, titleText])
/* ── 推荐音色选择 ── */
const handleSelectRecommendedVoice = useCallback( const handleSelectRecommendedVoice = useCallback(
(voiceId: string) => { (voiceId: string) => {
onVoiceModeChange("preset") onVoiceModeChange("preset")
@@ -136,173 +90,6 @@ export function useStep5Voice({
[onVoiceModeChange, onSelectedVoiceChange], [onVoiceModeChange, onSelectedVoiceChange],
) )
/* ── TTS 自定义合成状态 ── */
const [customVoiceText, setCustomVoiceText] = useState("")
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
const [ttsError, setTtsError] = useState<string | null>(null)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
/** 合成完成后保留的 job ID,用于"存为素材" */
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
/* ── TTS mutation ── */
const synthesizeMutation = useMutation({
mutationFn: synthesizeSpeech,
onSuccess: (data) => {
setTtsJobId(data.job_id)
message.info("语音合成已提交,等待处理…")
},
onError: () => {
setTtsError("语音合成请求失败,请重试")
},
})
/** 轮询 TTS 任务状态 */
useEffect(() => {
if (!ttsJobId) return
let cancelled = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
try {
const status = await getTTSJobStatus(ttsJobId)
if (cancelled) return
if (status.status === "completed") {
setCustomAudioUrl(status.output_audio_url)
setCompletedTtsJobId(ttsJobId)
setTtsJobId(null)
setTtsError(null)
message.success("语音合成完成!")
return
}
if (status.status === "failed" || status.status === "cancelled") {
setTtsError(status.error_message || "语音合成失败")
setTtsJobId(null)
return
}
timer = setTimeout(poll, 2000)
} catch {
if (!cancelled) {
setTtsError("查询合成状态失败")
setTtsJobId(null)
}
}
}
timer = setTimeout(poll, 2000)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [ttsJobId])
/** 触发自定义文本 TTS 合成 */
const handleSynthesizeVoice = useCallback(() => {
if (!customVoiceText.trim()) {
message.warning("请先输入配音文案")
return
}
setTtsError(null)
setCustomAudioUrl(null)
synthesizeMutation.mutate({
text: customVoiceText.trim(),
voice_id: selectedVoice || undefined,
language: "zh-CN",
})
}, [customVoiceText, selectedVoice, synthesizeMutation])
/* ── 存为素材弹窗状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [saveName, setSaveName] = useState("")
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
const [saveNewTag, setSaveNewTag] = useState("")
/* ── 标签列表(用于存为素材弹窗) ── */
const { data: allTags = [] } = useQuery({
queryKey: ["generate-save-tags"],
queryFn: getTags,
staleTime: 30_000,
})
/* ── 存为素材 mutation ── */
const saveToLibraryMutation = useMutation({
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
saveTtsToLibrary(completedTtsJobId!, params),
onSuccess: () => {
message.success({
content: (
<span>
{" "}
<a
onClick={handleGoToLibrary}
style={{
color: "var(--primary-500, #6366f1)",
cursor: "pointer",
}}
>
</a>
</span>
),
duration: 5,
})
setSaveModalOpen(false)
setSaveName("")
setSaveTagIds([])
setSaveNewTag("")
setCompletedTtsJobId(null)
setCustomAudioUrl(null)
},
onError: (err: Error) => {
message.error(`保存失败:${err.message || "请重试"}`)
},
})
/** 打开存为素材弹窗 */
const handleOpenSaveModal = useCallback(() => {
setSaveName("")
setSaveTagIds([])
setSaveNewTag("")
setSaveModalOpen(true)
}, [])
/** 确认保存 */
const handleConfirmSave = useCallback(() => {
if (!completedTtsJobId) return
saveToLibraryMutation.mutate({
name: saveName.trim() || undefined,
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
})
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
/** 在弹窗中新增标签(先创建再选中) */
const handleAddTagInModal = useCallback(
async (tagName: string) => {
const trimmed = tagName.trim()
if (!trimmed) return
/* 已在选中列表则跳过 */
const existing = allTags.find((t) => t.name === trimmed)
if (existing) {
if (!saveTagIds.includes(existing.id)) {
setSaveTagIds((prev) => [...prev, existing.id])
}
return
}
try {
const created = await createTag(trimmed)
setSaveTagIds((prev) => [...prev, created.id])
setSaveNewTag("")
} catch {
message.error(`创建标签"${trimmed}"失败`)
}
},
[allTags, saveTagIds],
)
/** 保存成功后跳转到视频库 */
const handleGoToLibrary = useCallback(() => {
navigate("/app/voice-materials")
}, [navigate])
/* ── 克隆成功回调 ── */ /* ── 克隆成功回调 ── */
const handleCloneSuccess = useCallback( const handleCloneSuccess = useCallback(
(voice: VoiceClone) => { (voice: VoiceClone) => {
+24 -167
View File
@@ -3,126 +3,39 @@
* 卡片视图展示用户已保存的剪辑模板 * 卡片视图展示用户已保存的剪辑模板
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成 * 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
*/ */
import React, { useState } from "react" import React from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { Typography, Input, Select, Button, Empty, Spin, Row, Col } from "antd"
import { import { SearchOutlined, AppstoreOutlined, PlusOutlined } from "@ant-design/icons"
Typography,
Card,
Input,
Select,
Tag,
Button,
Space,
Empty,
Spin,
Tooltip,
message,
Popconfirm,
Row,
Col,
} from "antd"
import {
SearchOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
VideoCameraOutlined,
AppstoreOutlined,
PlusOutlined,
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { import type { EditingTemplate } from "@/api/editing-planner"
getEditingTemplates, import { useMyTemplates } from "./hooks/useMyTemplates"
getTemplateCategories, import { TemplateCard } from "./components/TemplateCard"
deleteEditingTemplate,
createEditingTemplate,
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateMode,
} from "@/api/editing-planner"
import "./MyTemplates.css" import "./MyTemplates.css"
const { Title, Text } = Typography const { Title, Text } = Typography
const MyTemplates: React.FC = () => { const MyTemplates: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const {
searchText,
setSearchText,
filterCategory,
setFilterCategory,
templates,
categories,
isLoading,
handleCopy,
handleDelete,
} = useMyTemplates()
const [searchText, setSearchText] = useState("")
const [filterCategory, setFilterCategory] = useState("")
/* ── 数据查询 ── */
const { data: templates = [], isLoading } = useQuery({
queryKey: ["editing-templates", filterCategory, searchText],
queryFn: () =>
getEditingTemplates({
category: filterCategory || undefined,
tag: searchText || undefined,
}),
})
const { data: categories = [] } = useQuery({
queryKey: ["template-categories"],
queryFn: getTemplateCategories,
})
/* ── Mutations ── */
const deleteMutation = useMutation({
mutationFn: deleteEditingTemplate,
onSuccess: () => {
message.success("模板已删除")
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败")
},
})
const copyMutation = useMutation({
mutationFn: (tpl: EditingTemplate) =>
createEditingTemplate({
name: `${tpl.name}(副本)`,
mode: tpl.mode,
category: tpl.category,
tags: tpl.tags,
title_config: tpl.title_config,
subtitle_config: tpl.subtitle_config,
bgm_config: tpl.bgm_config,
estimated_duration:
tpl.estimated_duration ??
Math.round(
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
),
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
}),
onSuccess: () => {
message.success("模板已复制")
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败")
},
})
/* ── 操作 ── */
const handleEdit = (tpl: EditingTemplate) => { const handleEdit = (tpl: EditingTemplate) => {
navigate(`/editing-planner?template=${tpl.id}`) navigate(`/editing-planner?template=${tpl.id}`)
} }
const handleGenerate = (tpl: EditingTemplate) => { const handleGenerate = (tpl: EditingTemplate) => {
// 跳转到智能剪辑页面,统一从智能剪辑出片
navigate(`/generate?templateId=${tpl.id}`) navigate(`/generate?templateId=${tpl.id}`)
} }
const handleCopy = (tpl: EditingTemplate) => {
copyMutation.mutate(tpl)
}
const handleDelete = (id: string) => {
deleteMutation.mutate(id)
}
return ( return (
<div className="mt-page"> <div className="mt-page">
{/* 页面头部 */} {/* 页面头部 */}
@@ -179,69 +92,13 @@ const MyTemplates: React.FC = () => {
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{templates.map((tpl) => ( {templates.map((tpl) => (
<Col key={tpl.id} xs={24} sm={12} md={8} lg={6}> <Col key={tpl.id} xs={24} sm={12} md={8} lg={6}>
<Card <TemplateCard
className="mt-card" tpl={tpl}
hoverable onEdit={handleEdit}
actions={[ onCopy={handleCopy}
<Tooltip title="编辑" key="edit"> onGenerate={handleGenerate}
<EditOutlined onClick={() => handleEdit(tpl)} /> onDelete={handleDelete}
</Tooltip>, />
<Tooltip title="复制" key="copy">
<CopyOutlined onClick={() => handleCopy(tpl)} />
</Tooltip>,
<Tooltip title="使用模板生成" key="generate">
<VideoCameraOutlined onClick={() => handleGenerate(tpl)} />
</Tooltip>,
<Popconfirm
key="delete"
title="确定删除此模板?"
onConfirm={() => handleDelete(tpl.id)}
okText="删除"
cancelText="取消"
>
<Tooltip title="删除">
<DeleteOutlined style={{ color: "#ff4d4f" }} />
</Tooltip>
</Popconfirm>,
]}
>
<div className="mt-card-head">
<Text strong ellipsis style={{ fontSize: 15 }}>
{tpl.name}
</Text>
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || "default"}>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag>
<Tag color="green"></Tag>
</div>
<div className="mt-card-meta">
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} · ~{tpl.estimated_duration}s
</Text>
{tpl.category && (
<Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>
)}
</div>
{tpl.tags.length > 0 && (
<div className="mt-card-tags">
{tpl.tags.map((tag) => (
<Tag key={tag} style={{ fontSize: 11 }}>
{tag}
</Tag>
))}
</div>
)}
<div className="mt-card-config">
<Space size={4} wrap>
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
{tpl.subtitle_config.enabled && <Tag color="geekblue"></Tag>}
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
</Space>
</div>
</Card>
</Col> </Col>
))} ))}
</Row> </Row>
@@ -0,0 +1,92 @@
import React from "react"
import { Card, Tag, Tooltip, Popconfirm, Space, Typography } from "antd"
import { EditOutlined, CopyOutlined, DeleteOutlined, VideoCameraOutlined } from "@ant-design/icons"
import {
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateMode,
} from "@/api/editing-planner"
const { Text } = Typography
interface TemplateCardProps {
tpl: EditingTemplate
onEdit: (tpl: EditingTemplate) => void
onCopy: (tpl: EditingTemplate) => void
onGenerate: (tpl: EditingTemplate) => void
onDelete: (id: string) => void
}
/**
* 单个模板卡片组件
*/
export const TemplateCard: React.FC<TemplateCardProps> = ({
tpl,
onEdit,
onCopy,
onGenerate,
onDelete,
}) => (
<Card
className="mt-card"
hoverable
actions={[
<Tooltip title="编辑" key="edit">
<EditOutlined onClick={() => onEdit(tpl)} />
</Tooltip>,
<Tooltip title="复制" key="copy">
<CopyOutlined onClick={() => onCopy(tpl)} />
</Tooltip>,
<Tooltip title="使用模板生成" key="generate">
<VideoCameraOutlined onClick={() => onGenerate(tpl)} />
</Tooltip>,
<Popconfirm
key="delete"
title="确定删除此模板?"
onConfirm={() => onDelete(tpl.id)}
okText="删除"
cancelText="取消"
>
<Tooltip title="删除">
<DeleteOutlined style={{ color: "#ff4d4f" }} />
</Tooltip>
</Popconfirm>,
]}
>
<div className="mt-card-head">
<Text strong ellipsis style={{ fontSize: 15 }}>
{tpl.name}
</Text>
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || "default"}>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag>
<Tag color="green"></Tag>
</div>
<div className="mt-card-meta">
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} · ~{tpl.estimated_duration}s
</Text>
{tpl.category && <Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>}
</div>
{tpl.tags.length > 0 && (
<div className="mt-card-tags">
{tpl.tags.map((tag) => (
<Tag key={tag} style={{ fontSize: 11 }}>
{tag}
</Tag>
))}
</div>
)}
<div className="mt-card-config">
<Space size={4} wrap>
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
{tpl.subtitle_config.enabled && <Tag color="geekblue"></Tag>}
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
</Space>
</div>
</Card>
)
@@ -0,0 +1,100 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
getEditingTemplates,
getTemplateCategories,
deleteEditingTemplate,
createEditingTemplate,
type EditingTemplate,
} from "@/api/editing-planner"
/**
* 我的模板数据 Hook
* 封装模板列表查询、筛选、删除、复制等数据操作
*/
export function useMyTemplates() {
const queryClient = useQueryClient()
const [searchText, setSearchText] = useState("")
const [filterCategory, setFilterCategory] = useState("")
/* 模板列表 */
const { data: templates = [], isLoading } = useQuery({
queryKey: ["editing-templates", filterCategory, searchText],
queryFn: () =>
getEditingTemplates({
category: filterCategory || undefined,
tag: searchText || undefined,
}),
})
/* 分类列表 */
const { data: categories = [] } = useQuery({
queryKey: ["template-categories"],
queryFn: getTemplateCategories,
})
/* 删除 mutation */
const deleteMutation = useMutation({
mutationFn: deleteEditingTemplate,
onSuccess: () => {
message.success("模板已删除")
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败")
},
})
/* 复制 mutation */
const copyMutation = useMutation({
mutationFn: (tpl: EditingTemplate) =>
createEditingTemplate({
name: `${tpl.name}(副本)`,
mode: tpl.mode,
category: tpl.category,
tags: tpl.tags,
title_config: tpl.title_config,
subtitle_config: tpl.subtitle_config,
bgm_config: tpl.bgm_config,
estimated_duration:
tpl.estimated_duration ??
Math.round(
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
),
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
}),
onSuccess: () => {
message.success("模板已复制")
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败")
},
})
const handleCopy = (tpl: EditingTemplate) => {
copyMutation.mutate(tpl)
}
const handleDelete = (id: string) => {
deleteMutation.mutate(id)
}
return {
// 状态
searchText,
setSearchText,
filterCategory,
setFilterCategory,
// 数据
templates,
categories,
isLoading,
// 操作
handleCopy,
handleDelete,
isDeleting: deleteMutation.isPending,
isCopying: copyMutation.isPending,
}
}
+44 -197
View File
@@ -1,38 +1,29 @@
/** /**
* 成片库页面 — V21 设计系统 * 成片库页面 — V21 设计系统
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选 * 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
* 使用 useQuery 对接后端真实 APIapi/products.ts
* *
* 代码结构(三阶段重构后): * 主组件仅保留 Hook 组装与整体布局
* - types.ts: 类型定义 * 列表查询 → hooks/useProductList
* - constants.ts: 常量配置 * 操作逻辑 → hooks/useProductActions
* - utils/index.ts: 工具函数 * 筛选栏 → components/ProductFilterBar
* - components/ProductCard.tsx: 产品卡片组件 * 批量操作栏 → components/ProductBatchBar
* - components/VideoPlayer.tsx: 视频播放器组件 * 空状态 → components/ProductEmptyState
* - hooks/useProductList.ts: 列表查询与筛选 * 产品卡片 → components/ProductCard
* - hooks/useProductActions.ts: 单个/批量操作 * 视频播放 → components/VideoPlayer
*/ */
import React, { useState } from "react" import React, { useState } from "react"
import { Popconfirm, message } from "antd" import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
import { import { Button } from "@/components/ui"
SearchOutlined,
VideoCameraOutlined,
DownloadOutlined,
DeleteOutlined,
CheckOutlined,
CloudUploadOutlined,
} from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import type { ProductItem } from "./types" import type { ProductItem } from "./types"
import { ProductCard } from "./components/ProductCard" import { ProductCard } from "./components/ProductCard"
import { VideoPlayer } from "./components/VideoPlayer" import { VideoPlayer } from "./components/VideoPlayer"
import { ProductFilterBar } from "./components/ProductFilterBar"
import { ProductBatchBar } from "./components/ProductBatchBar"
import { ProductEmptyState } from "./components/ProductEmptyState"
import { useProductList } from "./hooks/useProductList" import { useProductList } from "./hooks/useProductList"
import { useProductActions } from "./hooks/useProductActions" import { useProductActions } from "./hooks/useProductActions"
import "./products.css" import "./products.css"
/* ============================================================
* 主组件
* ============================================================ */
const ProductLibrary: React.FC = () => { const ProductLibrary: React.FC = () => {
const { const {
products, products,
@@ -83,58 +74,20 @@ const ProductLibrary: React.FC = () => {
setPlayingProduct, setPlayingProduct,
}) })
// ── Loading 状态 ──
if (isLoading) { if (isLoading) {
return ( return <ProductEmptyState type="loading" />
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>...</p>
</div>
</div>
)
} }
// ── Error 状态 ── // ── Error 状态 ──
if (isError) { if (isError) {
console.error("[ProductLibrary] 加载失败:", error) console.error("[ProductLibrary] 加载失败:", error)
const errorMsg = error?.message || "加载失败" const errorMsg = error?.message || "加载失败"
// 404 视为空数据(API 尚未就绪或无数据)
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found") const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found")
if (is404) { if (is404) {
return ( return <ProductEmptyState type="404" />
<div className="xx-products-page">
<div className="xx-products-header">
<h2>
<VideoCameraOutlined />
</h2>
</div>
<div className="xx-products-empty">
<div className="xx-products-empty-icon">🎬</div>
<p></p>
<p
style={{
fontSize: 12,
color: "var(--text-tertiary)",
marginTop: 4,
}}
>
</p>
</div>
</div>
)
} }
return ( return <ProductEmptyState type="error" errorMessage={errorMsg} onRetry={refetch} />
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>{errorMsg || "加载失败,请稍后重试"}</p>
<Button buttonType="primary" buttonSize="sm" onClick={() => refetch()}>
</Button>
</div>
</div>
)
} }
return ( return (
@@ -153,129 +106,35 @@ const ProductLibrary: React.FC = () => {
{/* 批量操作栏 */} {/* 批量操作栏 */}
{batchMode && ( {batchMode && (
<div className="xx-products-batch-bar"> <ProductBatchBar
<div className="xx-products-batch-bar-left"> allSelected={allSelected}
<div selectedCount={selectedIds.size}
className={`xx-products-checkbox${allSelected ? " checked" : ""}`} batchDownloading={batchDownloading}
onClick={handleSelectAll} onSelectAll={handleSelectAll}
> onBatchDownload={handleBatchDownload}
{allSelected && <CheckOutlined />} onBatchPublish={handleBatchPublish}
</div> onBatchDelete={handleBatchDelete}
<span className="xx-products-select-all" onClick={handleSelectAll}> onClearSelection={clearSelection}
{allSelected ? "取消全选" : "全选"} />
</span>
<span className="xx-products-batch-count"> {selectedIds.size} </span>
</div>
<div className="xx-products-batch-bar-right">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<DownloadOutlined />}
onClick={handleBatchDownload}
disabled={batchDownloading}
>
{batchDownloading ? "打包中..." : "批量下载"}
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<CloudUploadOutlined />}
onClick={handleBatchPublish}
>
</Button>
<Popconfirm
title={`确定删除选中的 ${selectedIds.size} 个视频?`}
onConfirm={handleBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
</Button>
</div>
</div>
)} )}
{/* 筛选栏 */} {/* 筛选栏 */}
<div className="xx-products-filters"> <ProductFilterBar
<div className="xx-products-filters-left"> searchText={searchText}
<Input onSearchChange={setSearchText}
placeholder="搜索成片名称..." filterStatus={filterStatus}
prefix={<SearchOutlined />} onFilterStatusChange={setFilterStatus}
value={searchText} filterTime={filterTime}
onChange={(e) => setSearchText(e.target.value)} onFilterTimeChange={setFilterTime}
allowClear filterDuration={filterDuration}
style={{ width: 220 }} onFilterDurationChange={setFilterDuration}
/> filterProject={filterProject}
<Select onFilterProjectChange={setFilterProject}
value={filterStatus} filterReviewStatus={filterReviewStatus}
onChange={setFilterStatus} onFilterReviewStatusChange={setFilterReviewStatus}
style={{ width: 120 }} projectOptions={projectOptions}
options={[ resultCount={filteredProducts.length}
{ value: "all", label: "全部状态" }, />
{ value: "completed", label: "已完成" },
{ value: "processing", label: "处理中" },
{ value: "review", label: "待复核" },
{ value: "failed", label: "失败" },
]}
/>
<Select
value={filterTime}
onChange={setFilterTime}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一月" },
]}
/>
<Select
value={filterDuration}
onChange={setFilterDuration}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部时长" },
{ value: "short", label: "≤1分钟" },
{ value: "medium", label: "1-3分钟" },
{ value: "long", label: ">3分钟" },
]}
/>
<Select
value={filterProject}
onChange={setFilterProject}
style={{ width: 140 }}
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
/>
<Select
value={filterReviewStatus}
onChange={setFilterReviewStatus}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部复核" },
{ value: "none", label: "未设置" },
{ value: "pending_review", label: "待复核" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "需修改" },
]}
/>
</div>
<div className="xx-products-filters-right">
<span
style={{
fontSize: "var(--font-size-sm)",
color: "var(--text-tertiary)",
}}
>
{filteredProducts.length}
</span>
</div>
</div>
{/* 卡片网格 */} {/* 卡片网格 */}
{filteredProducts.length > 0 ? ( {filteredProducts.length > 0 ? (
@@ -297,19 +156,7 @@ const ProductLibrary: React.FC = () => {
))} ))}
</div> </div>
) : ( ) : (
<div className="xx-products-empty"> <ProductEmptyState type="empty" />
<div className="xx-products-empty-icon">
<VideoCameraOutlined />
</div>
<p></p>
<Button
buttonType="primary"
buttonSize="sm"
onClick={() => message.info("跳转到生成页面")}
>
</Button>
</div>
)} )}
{/* 视频播放弹窗 */} {/* 视频播放弹窗 */}
@@ -0,0 +1,85 @@
/**
* ProductLibrary 批量操作栏
*/
import React from "react"
import {
CheckOutlined,
DownloadOutlined,
DeleteOutlined,
CloudUploadOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui"
import { Popconfirm } from "antd"
export interface ProductBatchBarProps {
allSelected: boolean
selectedCount: number
batchDownloading: boolean
onSelectAll: () => void
onBatchDownload: () => void
onBatchPublish: () => void
onBatchDelete: () => void
onClearSelection: () => void
}
export const ProductBatchBar: React.FC<ProductBatchBarProps> = ({
allSelected,
selectedCount,
batchDownloading,
onSelectAll,
onBatchDownload,
onBatchPublish,
onBatchDelete,
onClearSelection,
}) => {
return (
<div className="xx-products-batch-bar">
<div className="xx-products-batch-bar-left">
<div
className={`xx-products-checkbox${allSelected ? " checked" : ""}`}
onClick={onSelectAll}
>
{allSelected && <CheckOutlined />}
</div>
<span className="xx-products-select-all" onClick={onSelectAll}>
{allSelected ? "取消全选" : "全选"}
</span>
<span className="xx-products-batch-count"> {selectedCount} </span>
</div>
<div className="xx-products-batch-bar-right">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<DownloadOutlined />}
onClick={onBatchDownload}
disabled={batchDownloading}
>
{batchDownloading ? "打包中..." : "批量下载"}
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<CloudUploadOutlined />}
onClick={onBatchPublish}
>
</Button>
<Popconfirm
title={`确定删除选中的 ${selectedCount} 个视频?`}
onConfirm={onBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
<Button buttonType="ghost" buttonSize="sm" onClick={onClearSelection}>
</Button>
</div>
</div>
)
}
export default ProductBatchBar
@@ -0,0 +1,88 @@
/**
* ProductLibrary 空状态/加载/错误页面
*/
import React from "react"
import { VideoCameraOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import { message } from "antd"
export type ProductEmptyStateType = "loading" | "empty" | "404" | "error"
export interface ProductEmptyStateProps {
type: ProductEmptyStateType
errorMessage?: string
onRetry?: () => void
}
export const ProductEmptyState: React.FC<ProductEmptyStateProps> = ({
type,
errorMessage,
onRetry,
}) => {
if (type === "loading") {
return (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>...</p>
</div>
</div>
)
}
if (type === "404") {
return (
<div className="xx-products-page">
<div className="xx-products-header">
<h2>
<VideoCameraOutlined />
</h2>
</div>
<div className="xx-products-empty">
<div className="xx-products-empty-icon">🎬</div>
<p></p>
<p
style={{
fontSize: 12,
color: "var(--text-tertiary)",
marginTop: 4,
}}
>
</p>
</div>
</div>
)
}
if (type === "error") {
return (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>{errorMessage || "加载失败,请稍后重试"}</p>
{onRetry && (
<Button buttonType="primary" buttonSize="sm" onClick={onRetry}>
</Button>
)}
</div>
</div>
)
}
// empty
return (
<div className="xx-products-empty">
<div className="xx-products-empty-icon">
<VideoCameraOutlined />
</div>
<p></p>
<Button buttonType="primary" buttonSize="sm" onClick={() => message.info("跳转到生成页面")}>
</Button>
</div>
)
}
export default ProductEmptyState
@@ -0,0 +1,119 @@
/**
* ProductLibrary 筛选栏
*/
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import { Input, Select } from "@/components/ui"
export interface ProductFilterBarProps {
searchText: string
onSearchChange: (text: string) => void
filterStatus: string
onFilterStatusChange: (val: string) => void
filterTime: string
onFilterTimeChange: (val: string) => void
filterDuration: string
onFilterDurationChange: (val: string) => void
filterProject: string
onFilterProjectChange: (val: string) => void
filterReviewStatus: string
onFilterReviewStatusChange: (val: string) => void
projectOptions: Array<{ value: string; label: string }>
resultCount: number
}
export const ProductFilterBar: React.FC<ProductFilterBarProps> = ({
searchText,
onSearchChange,
filterStatus,
onFilterStatusChange,
filterTime,
onFilterTimeChange,
filterDuration,
onFilterDurationChange,
filterProject,
onFilterProjectChange,
filterReviewStatus,
onFilterReviewStatusChange,
projectOptions,
resultCount,
}) => {
return (
<div className="xx-products-filters">
<div className="xx-products-filters-left">
<Input
placeholder="搜索成片名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterStatus}
onChange={onFilterStatusChange}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部状态" },
{ value: "completed", label: "已完成" },
{ value: "processing", label: "处理中" },
{ value: "review", label: "待复核" },
{ value: "failed", label: "失败" },
]}
/>
<Select
value={filterTime}
onChange={onFilterTimeChange}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一月" },
]}
/>
<Select
value={filterDuration}
onChange={onFilterDurationChange}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部时长" },
{ value: "short", label: "≤1分钟" },
{ value: "medium", label: "1-3分钟" },
{ value: "long", label: ">3分钟" },
]}
/>
<Select
value={filterProject}
onChange={onFilterProjectChange}
style={{ width: 140 }}
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
/>
<Select
value={filterReviewStatus}
onChange={onFilterReviewStatusChange}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部复核" },
{ value: "none", label: "未设置" },
{ value: "pending_review", label: "待复核" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "需修改" },
]}
/>
</div>
<div className="xx-products-filters-right">
<span
style={{
fontSize: "var(--font-size-sm)",
color: "var(--text-tertiary)",
}}
>
{resultCount}
</span>
</div>
</div>
)
}
export default ProductFilterBar
@@ -2,151 +2,59 @@
* 升级/降级/续费页面 * 升级/降级/续费页面
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件 * P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
*/ */
import React, { useState, useEffect } from "react" import React from "react"
import { message } from "antd" import { Modal } from "@/components/ui"
import { Button, Modal } from "@/components/ui"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { import type { PlanType } from "@/api/subscription"
getCurrentSubscription,
changePlan,
toggleAutoRenew,
cancelSubscription,
} from "@/api/subscription"
import type { SubscriptionInfo, PlanType, BillingCycle } from "@/api/subscription"
import PageHead from "@/components/layout/PageHead" import PageHead from "@/components/layout/PageHead"
import { Button } from "@/components/ui"
import { PLANS_META, getPlanName, getPlanPrice } from "./constants"
import { BillingCycleSwitch, Spinner } from "./components/SubscriptionUI"
import { useSubscription } from "./hooks/useSubscription"
import "./UpgradeSubscription.css" import "./UpgradeSubscription.css"
const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = {
free: { name: "体验版", price: 0, yearlyPrice: 0 },
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
}
/** 自定义计费周期切换组件 */
const BillingCycleSwitch: React.FC<{
value: BillingCycle
onChange: (cycle: BillingCycle) => void
monthlyPrice: number
yearlyPrice: number
}> = ({ value, onChange, monthlyPrice, yearlyPrice }) => (
<div className="xx-billing-cycle-switch">
<button
type="button"
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
onClick={() => onChange("monthly")}
>
¥{monthlyPrice}/
</button>
<button
type="button"
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
onClick={() => onChange("yearly")}
>
¥{yearlyPrice}/
{yearlyPrice > 0 && monthlyPrice > 0 && (
<span className="xx-save"> ¥{monthlyPrice * 12 - yearlyPrice}</span>
)}
</button>
</div>
)
/** 自定义 Spinner 组件 */
const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
<div className={`xx-spinner xx-spinner--${size}`}>
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
</div>
)
const UpgradeSubscription: React.FC = () => { const UpgradeSubscription: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null) const {
const [loading, setLoading] = useState(true) subscription,
const [submitting, setSubmitting] = useState(false) loading,
const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard") submitting,
const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly") selectedPlan,
billingCycle,
setSelectedPlan,
setBillingCycle,
executeChangePlan,
handleToggleAutoRenew,
handleCancel,
} = useSubscription()
useEffect(() => { const handleUpgradeClick = () => {
loadSubscription()
}, [])
const loadSubscription = async () => {
try {
const data = await getCurrentSubscription()
setSubscription(data)
setSelectedPlan(data.plan_id)
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
} finally {
setLoading(false)
}
}
const handleUpgrade = async () => {
if (!subscription) return if (!subscription) return
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) { if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
message.info("当前已是该套餐")
return return
} }
const plan = PLANS_META[selectedPlan] const plan = PLANS_META[selectedPlan]
const price = billingCycle === "yearly" ? plan.yearlyPrice : plan.price const price = getPlanPrice(selectedPlan, billingCycle)
Modal.confirm({ Modal.confirm({
title: "确认变更套餐", title: "确认变更套餐",
content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`, content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`,
okText: "确认变更", okText: "确认变更",
cancelText: "取消", cancelText: "取消",
onOk: async () => { onOk: executeChangePlan,
try {
setSubmitting(true)
const res = await changePlan({
target_plan_id: selectedPlan,
billing_cycle: billingCycle,
})
if (res.success) {
message.success(res.message)
setSubscription(res.new_subscription ?? null)
} else {
message.error(res.message)
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
} finally {
setSubmitting(false)
}
},
}) })
} }
const handleToggleAutoRenew = async (enabled: boolean) => { const handleCancelClick = () => {
try {
const res = await toggleAutoRenew(enabled)
message.success(res.message)
if (subscription) {
setSubscription({ ...subscription, auto_renew: enabled })
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
}
}
const handleCancel = () => {
Modal.confirm({ Modal.confirm({
title: "确认取消订阅", title: "确认取消订阅",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。", content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
okText: "确认取消", okText: "确认取消",
cancelText: "再想想", cancelText: "再想想",
onOk: async () => { onOk: async () => {
try { const ok = await handleCancel()
const res = await cancelSubscription() if (ok) navigate("/app/subscription")
message.success(res.message)
navigate("/app/subscription")
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
}
}, },
}) })
} }
@@ -163,10 +71,7 @@ const UpgradeSubscription: React.FC = () => {
return ( return (
<div className="xx-upgrade-page"> <div className="xx-upgrade-page">
<PageHead <PageHead title="变更订阅方案" description={`当前套餐:${getPlanName(currentPlan)}`} />
title="变更订阅方案"
description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`}
/>
<div className="xx-upgrade-plans"> <div className="xx-upgrade-plans">
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => { {(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
@@ -198,7 +103,7 @@ const UpgradeSubscription: React.FC = () => {
buttonType="primary" buttonType="primary"
buttonSize="lg" buttonSize="lg"
disabled={submitting || selectedPlan === currentPlan} disabled={submitting || selectedPlan === currentPlan}
onClick={handleUpgrade} onClick={handleUpgradeClick}
> >
{submitting ? "处理中..." : "确认变更"} {submitting ? "处理中..." : "确认变更"}
</Button> </Button>
@@ -220,7 +125,7 @@ const UpgradeSubscription: React.FC = () => {
<Button <Button
buttonType="danger" buttonType="danger"
buttonSize="sm" buttonSize="sm"
onClick={handleCancel} onClick={handleCancelClick}
className="xx-cancel-btn" className="xx-cancel-btn"
> >
@@ -0,0 +1,50 @@
import React from "react"
import type { BillingCycle } from "@/api/subscription"
interface BillingCycleSwitchProps {
value: BillingCycle
onChange: (cycle: BillingCycle) => void
monthlyPrice: number
yearlyPrice: number
}
/** 自定义计费周期切换组件 */
export const BillingCycleSwitch: React.FC<BillingCycleSwitchProps> = ({
value,
onChange,
monthlyPrice,
yearlyPrice,
}) => (
<div className="xx-billing-cycle-switch">
<button
type="button"
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
onClick={() => onChange("monthly")}
>
¥{monthlyPrice}/
</button>
<button
type="button"
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
onClick={() => onChange("yearly")}
>
¥{yearlyPrice}/
{yearlyPrice > 0 && monthlyPrice > 0 && (
<span className="xx-save"> ¥{monthlyPrice * 12 - yearlyPrice}</span>
)}
</button>
</div>
)
interface SpinnerProps {
size?: "small" | "large"
}
/** 自定义 Spinner 组件 */
export const Spinner: React.FC<SpinnerProps> = ({ size = "large" }) => (
<div className={`xx-spinner xx-spinner--${size}`}>
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
</div>
)
@@ -0,0 +1,16 @@
import type { PlanType, BillingCycle } from "@/api/subscription"
export const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = {
free: { name: "体验版", price: 0, yearlyPrice: 0 },
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
}
export const getPlanName = (planId: PlanType | string) => PLANS_META[planId]?.name ?? "体验版"
export const getPlanPrice = (planId: PlanType | string, cycle: BillingCycle) => {
const plan = PLANS_META[planId]
if (!plan) return 0
return cycle === "yearly" ? plan.yearlyPrice : plan.price
}
@@ -0,0 +1,111 @@
import { useState, useEffect, useCallback } from "react"
import { message } from "antd"
import {
getCurrentSubscription,
changePlan,
toggleAutoRenew,
cancelSubscription,
type SubscriptionInfo,
type PlanType,
type BillingCycle,
} from "@/api/subscription"
/**
* 订阅管理 Hook
* 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑
*/
export function useSubscription() {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard")
const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly")
const loadSubscription = useCallback(async () => {
try {
const data = await getCurrentSubscription()
setSubscription(data)
setSelectedPlan(data.plan_id)
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadSubscription()
}, [loadSubscription])
const handleUpgrade = useCallback(async () => {
if (!subscription) return
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
message.info("当前已是该套餐")
return
}
// 由调用方决定是否弹确认框
}, [subscription, selectedPlan, billingCycle])
const executeChangePlan = useCallback(async () => {
try {
setSubmitting(true)
const res = await changePlan({
target_plan_id: selectedPlan,
billing_cycle: billingCycle,
})
if (res.success) {
message.success(res.message)
setSubscription(res.new_subscription ?? null)
} else {
message.error(res.message)
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
} finally {
setSubmitting(false)
}
}, [selectedPlan, billingCycle])
const handleToggleAutoRenew = useCallback(
async (enabled: boolean) => {
try {
const res = await toggleAutoRenew(enabled)
message.success(res.message)
if (subscription) {
setSubscription({ ...subscription, auto_renew: enabled })
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
}
},
[subscription],
)
const handleCancel = useCallback(async () => {
try {
const res = await cancelSubscription()
message.success(res.message)
return true
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
return false
}
}, [])
return {
// 状态
subscription,
loading,
submitting,
selectedPlan,
billingCycle,
setSelectedPlan,
setBillingCycle,
// 操作
loadSubscription,
handleUpgrade,
executeChangePlan,
handleToggleAutoRenew,
handleCancel,
}
}
+71 -680
View File
@@ -6,708 +6,99 @@
* - 复制模板 / 从模板生成 * - 复制模板 / 从模板生成
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏 * - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
*/ */
import React, { useState, useMemo, useCallback } from "react" import React from "react"
import { useNavigate } from "react-router-dom" import { useTemplateLibrary } from "./hooks/useTemplateLibrary"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useTemplateDetail } from "./hooks/useTemplateDetail"
import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd" import { TemplateHeader } from "./components/template-library/TemplateHeader"
import { import { TemplateToolbar } from "./components/template-library/TemplateToolbar"
LoadingOutlined, import { TemplateGrid } from "./components/template-library/TemplateGrid"
ExclamationCircleOutlined, import { TemplateDetailModal } from "./components/template-library/TemplateDetailModal"
InboxOutlined,
SearchOutlined,
CopyOutlined,
ThunderboltOutlined,
} from "@ant-design/icons"
import {
getTemplates,
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
type TemplateItem,
type TemplateListParams,
type TemplateSegment,
} from "@/api/templates"
import "./templates.css" import "./templates.css"
/* ============================================================
* 类型定义
* ============================================================ */
/** 模板类型 */
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"
/* ============================================================
* 模板类型配置
* ============================================================ */
const TEMPLATE_TYPES: Array<{
type: EditTemplateType | "全部"
label: string
icon: string
color: string
}> = [
{ type: "全部", label: "全部", icon: "📋", color: "#6366f1" },
{ type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" },
{ type: "种草", label: "种草", icon: "🌱", color: "#10b981" },
{ type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" },
{ type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" },
{ type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" },
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
]
/** 时长筛选选项 */
const DURATION_OPTIONS: Array<{
value: "" | "short" | "medium" | "long"
label: string
}> = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
/* ============================================================
* 辅助函数
* ============================================================ */
/** 获取类型对应颜色 */
const getTypeColor = (type: string): string => {
const found = TEMPLATE_TYPES.find((t) => t.type === type)
return found?.color ?? "#6366f1"
}
/** 根据 category 生成占位渐变色 */
const gradientForCategory = (category: string): string => {
const gradients: Record<string, string> = {
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
: "linear-gradient(135deg, #10b981, #059669)",
: "linear-gradient(135deg, #0ea5e9, #0284c7)",
: "linear-gradient(135deg, #f59e0b, #d97706)",
: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
}
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)"
}
/** 格式化时长 */
const formatDuration = (seconds: number | undefined | null): string => {
if (!seconds || seconds <= 0) return "0秒"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
if (m === 0) return `${s}`
return `${m}${s > 0 ? `${s}` : ""}`
}
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
interface ConfigDisplayFields {
font_size?: string | number
font_family?: string
color?: string
position?: string
volume?: string | number
name?: string
}
/** 格式化配置对象为可读文本 */
const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认"
const c = config as ConfigDisplayFields
const parts: string[] = []
if (c.font_size) parts.push(`字号: ${c.font_size}`)
if (c.font_family) parts.push(`字体: ${c.font_family}`)
if (c.color) parts.push(`颜色: ${c.color}`)
if (c.position) parts.push(`位置: ${c.position}`)
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`)
if (c.name) parts.push(String(c.name))
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config)
}
/** 素材类型标签 */
const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
subtitle: "字幕",
null: "不限",
}
/* ============================================================
* 模板详情弹窗组件
* ============================================================ */
interface TemplateDetailModalProps {
template: TemplateItem
isFavorite: boolean
onClose: () => void
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
isFavorite,
onClose,
onToggleFavorite,
onUse,
onCopy,
}) => {
const segments = template.segments ?? []
const totalSegmentDuration = segments.reduce(
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
0,
)
return (
<div className="xx-template-modal-overlay" onClick={onClose}>
<div
className="xx-template-modal xx-template-modal-wide"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
</button>
{/* 预览区域 */}
<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">
<h3>{template.name}</h3>
<span
className="xx-template-modal-type-badge"
style={{
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
</span>
</div>
{/* 描述 */}
<p className="xx-template-modal-desc">{template.description}</p>
{/* 标签 */}
{(template.tags?.length ?? 0) > 0 && (
<div className="xx-template-modal-tags">
{template.tags!.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
)}
{/* 基本信息 */}
<Descriptions
column={2}
size="small"
className="xx-template-modal-desc-table"
items={[
{
key: "duration",
label: "目标时长",
children: formatDuration(template.estimated_duration ?? template.target_duration),
},
{
key: "clips",
label: "片段数量",
children: `${template.clip_count}`,
},
{
key: "ratio",
label: "视频比例",
children: template.aspect_ratio ?? "16:9",
},
{
key: "usage",
label: "使用次数",
children: `${template.usage_count ?? 0}`,
},
]}
/>
{/* 素材规则(片段配置) */}
{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",
}}
>
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
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>
</div>
</div>
</div>
)
}
/* ============================================================
* 模板卡片组件
* ============================================================ */
interface TemplateCardProps {
template: TemplateItem
isFavorite: boolean
onPreview: (template: TemplateItem) => void
onToggleFavorite: (id: string, e: React.MouseEvent) => void
onUse: (template: TemplateItem) => void
}
const TemplateCard: React.FC<TemplateCardProps> = ({
template,
isFavorite,
onPreview,
onToggleFavorite,
onUse,
}) => {
return (
<div className="xx-template-card" onClick={() => onPreview(template)}>
{/* 缩略图 */}
<div className="xx-template-thumb">
{template.thumbnail_url ? (
<img src={template.thumbnail_url} alt={template.name} className="xx-template-thumb-img" />
) : (
<div
className="xx-template-thumb-bg"
style={{ background: gradientForCategory(template.category) }}
>
{(template.description ?? "").slice(0, 80)}
{(template.description ?? "").length > 80 ? "..." : ""}
</div>
)}
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{formatDuration(template.estimated_duration ?? template.target_duration)}
</span>
</div>
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={(e) => onToggleFavorite(template.id, e)}
title={isFavorite ? "取消收藏" : "收藏"}
>
{isFavorite ? "★" : "☆"}
</button>
</div>
{/* 信息区 */}
<div className="xx-template-info">
<div className="xx-template-info-top">
<span
className="xx-template-category-pill"
style={{
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
}}
>
{template.category}
</span>
{(template.tags ?? []).slice(0, 2).map((tag) => (
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
{tag}
</Tag>
))}
</div>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">使 {template.usage_count ?? 0} </span>
<button
className="xx-template-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse(template)
}}
>
使
</button>
</div>
</div>
</div>
)
}
/* ============================================================
* 主组件
* ============================================================ */
const TemplateLibrary: React.FC = () => { const TemplateLibrary: React.FC = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
// 筛选状态
const [searchText, setSearchText] = useState("")
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部")
const [durationRange, setDurationRange] = useState<"" | "short" | "medium" | "long">("")
const [page, setPage] = useState(1)
const [pageSize] = useState(12)
// 弹窗状态
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// ── 构建查询参数 ──
const queryParams: TemplateListParams = useMemo(() => {
const params: TemplateListParams = {
page,
page_size: pageSize,
}
if (activeType !== "全部") params.category = activeType
if (searchText.trim()) params.keyword = searchText.trim()
if (durationRange) params.duration_range = durationRange
return params
}, [page, pageSize, activeType, searchText, durationRange])
// ── 获取模板列表(后端分页 + 筛选) ──
const { const {
data: templateData, templates,
totalTemplates,
isLoading, isLoading,
isError, isError,
error, error,
} = useQuery({ searchText,
queryKey: ["templates", queryParams], activeType,
queryFn: () => getTemplates(queryParams), durationRange,
staleTime: 30_000, page,
pageSize,
setPage,
toggleFavorite,
handleCopy,
handleUse,
handleCreate,
handleSearchChange,
handleCategoryChange,
handleDurationChange,
} = useTemplateLibrary()
const {
previewTemplate,
detailLoading,
handlePreview,
handleClose,
handleToggleFavorite,
handleUse: handleUseFromDetail,
handleCopy: handleCopyFromDetail,
} = useTemplateDetail({
onToggleFavorite: (id) => toggleFavorite(id),
onUse: handleUse,
onCopy: handleCopy,
}) })
const templates = templateData?.items ?? []
const totalTemplates = templateData?.total ?? 0
// ── 收藏 mutation ──
const favMutation = useMutation({
mutationFn: toggleFavoriteTemplate,
onSuccess: (_data, templateId) => {
queryClient.invalidateQueries({ queryKey: ["templates"] })
if (previewTemplate && previewTemplate.id === templateId) {
setPreviewTemplate((prev) => (prev ? { ...prev, is_favorite: !prev.is_favorite } : prev))
}
},
})
// ── 复制模板 mutation ──
const copyMutation = useMutation({
mutationFn: copyTemplate,
onSuccess: (data) => {
message.success(`模板「${data.name}」已复制到「我的模板」`)
queryClient.invalidateQueries({ queryKey: ["templates"] })
},
onError: () => {
message.error("复制模板失败,请稍后重试")
},
})
/** 切换收藏 */
const toggleFavorite = useCallback(
(id: string, e?: React.MouseEvent) => {
e?.stopPropagation()
favMutation.mutate(id)
},
[favMutation],
)
/** 点击卡片 → 获取详情并展示弹窗 */
const handlePreview = useCallback(async (template: TemplateItem) => {
setDetailLoading(true)
setPreviewTemplate(template)
try {
const detail = await getTemplate(template.id)
setPreviewTemplate(detail)
} catch {
// 详情加载失败时使用列表数据
message.warning("模板详情加载失败,显示摘要信息")
} finally {
setDetailLoading(false)
}
}, [])
/** 复制模板 */
const handleCopy = useCallback(
(template: TemplateItem) => {
copyMutation.mutate(template.id)
},
[copyMutation],
)
/** 使用模板 → 进入剪辑编辑器配置 */
const handleUse = useCallback(
(template: TemplateItem) => {
navigate(`/app/editing-planner?templateId=${template.id}`)
},
[navigate],
)
/** 搜索防抖处理 */
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value)
setPage(1)
}, [])
/** 切换分类 */
const handleCategoryChange = useCallback((type: EditTemplateType | "全部") => {
setActiveType(type)
setPage(1)
}, [])
/** 切换时长筛选 */
const handleDurationChange = useCallback((value: "" | "short" | "medium" | "long") => {
setDurationRange(value)
setPage(1)
}, [])
// ── Loading 状态 ──
if (isLoading) {
return (
<div className="xx-templates-page">
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
<LoadingOutlined />
</div>
<h3>...</h3>
</div>
</div>
)
}
// ── Error 状态 ──
if (isError) {
return (
<div className="xx-templates-page">
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
<ExclamationCircleOutlined />
</div>
<h3></h3>
<p>{error?.message || "网络异常,请稍后重试"}</p>
</div>
</div>
)
}
return ( return (
<div className="xx-templates-page"> <div className="xx-templates-page">
{/* ── 页面头部 ──────────────────────────────────────────── */} {/* 页面头部 */}
<div className="xx-templates-header"> <TemplateHeader onCreateClick={handleCreate} />
<div className="xx-templates-header-text">
<h2></h2>
<p></p>
</div>
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
+
</Button>
</div>
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */} {/* 工具栏:搜索 + 类型按钮组 + 时长筛选 */}
<div className="xx-templates-toolbar"> <TemplateToolbar
<div className="xx-templates-search"> searchText={searchText}
<span className="xx-templates-search-icon"> onSearchChange={handleSearchChange}
<SearchOutlined /> activeType={activeType}
</span> onTypeChange={handleCategoryChange}
<input durationRange={durationRange}
className="xx-templates-search-input" onDurationChange={handleDurationChange}
type="text" />
placeholder="搜索模板名称、描述或标签..."
value={searchText}
onChange={handleSearchChange}
/>
</div>
<div className="xx-templates-categories">
{TEMPLATE_TYPES.map((cat) => (
<button
key={cat.type}
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
onClick={() => handleCategoryChange(cat.type)}
>
<span className="xx-templates-cat-icon">{cat.icon}</span>
{cat.label}
</button>
))}
</div>
{/* 时长筛选 */}
<div className="xx-templates-duration-filter">
{DURATION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
onClick={() => handleDurationChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* ── 模板展示区 ────────────────────────────────────────── */} {/* 模板展示区 */}
{templates.length === 0 ? ( <TemplateGrid
<div className="xx-templates-empty"> templates={templates}
<div className="xx-templates-empty-icon"> total={totalTemplates}
<InboxOutlined /> page={page}
</div> pageSize={pageSize}
<h3> isLoading={isLoading}
{searchText || activeType !== "全部" || durationRange ? "未找到匹配的模板" : "暂无模板"} isError={isError}
</h3> errorMessage={error?.message}
<p> searchText={searchText}
{searchText || activeType !== "全部" || durationRange activeType={activeType}
? "试试调整搜索条件或切换类型" durationRange={durationRange}
: "点击上方「创建模板」开始创作"} onPageChange={setPage}
</p> onPreview={handlePreview}
</div> onToggleFavorite={toggleFavorite}
) : ( onUse={handleUse}
<> />
<div className="xx-templates-grid">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.is_favorite ?? false}
onPreview={handlePreview}
onToggleFavorite={toggleFavorite}
onUse={handleUse}
/>
))}
</div>
{/* 分页 */} {/* 详情弹窗 */}
{totalTemplates > pageSize && (
<div className="xx-templates-pagination">
<Pagination
current={page}
pageSize={pageSize}
total={totalTemplates}
showSizeChanger={false}
showQuickJumper
showTotal={(total) => `${total} 个模板`}
onChange={(p) => setPage(p)}
/>
</div>
)}
</>
)}
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
{previewTemplate && ( {previewTemplate && (
<TemplateDetailModal <TemplateDetailModal
template={previewTemplate} template={previewTemplate}
isFavorite={previewTemplate.is_favorite ?? false} isFavorite={previewTemplate.is_favorite ?? false}
onClose={() => setPreviewTemplate(null)} onClose={handleClose}
onToggleFavorite={toggleFavorite} onToggleFavorite={handleToggleFavorite}
onUse={handleUse} onUse={handleUseFromDetail}
onCopy={handleCopy} onCopy={handleCopyFromDetail}
/> />
)} )}
{/* 详情加载中的提示(可选覆盖层) */} {/* 详情加载中的提示 */}
{detailLoading && previewTemplate && ( {detailLoading && previewTemplate && (
<div className="xx-template-detail-loading"> <div className="xx-template-detail-loading">...</div>
<LoadingOutlined /> ...
</div>
)} )}
</div> </div>
) )
@@ -0,0 +1,87 @@
import React from "react"
import { Tag } from "antd"
import type { TemplateItem } from "@/api/templates"
import { gradientForCategory, getTypeColor, formatDuration } from "../../utils/templateLibrary"
interface TemplateCardProps {
template: TemplateItem
isFavorite: boolean
onPreview: (template: TemplateItem) => void
onToggleFavorite: (id: string, e: React.MouseEvent) => void
onUse: (template: TemplateItem) => void
}
export const TemplateCard: React.FC<TemplateCardProps> = ({
template,
isFavorite,
onPreview,
onToggleFavorite,
onUse,
}) => {
return (
<div className="xx-template-card" onClick={() => onPreview(template)}>
{/* 缩略图 */}
<div className="xx-template-thumb">
{template.thumbnail_url ? (
<img src={template.thumbnail_url} alt={template.name} className="xx-template-thumb-img" />
) : (
<div
className="xx-template-thumb-bg"
style={{ background: gradientForCategory(template.category) }}
>
{(template.description ?? "").slice(0, 80)}
{(template.description ?? "").length > 80 ? "..." : ""}
</div>
)}
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{formatDuration(template.estimated_duration ?? template.target_duration)}
</span>
</div>
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={(e) => onToggleFavorite(template.id, e)}
title={isFavorite ? "取消收藏" : "收藏"}
>
{isFavorite ? "★" : "☆"}
</button>
</div>
{/* 信息区 */}
<div className="xx-template-info">
<div className="xx-template-info-top">
<span
className="xx-template-category-pill"
style={{
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
}}
>
{template.category}
</span>
{(template.tags ?? []).slice(0, 2).map((tag) => (
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
{tag}
</Tag>
))}
</div>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">使 {template.usage_count ?? 0} </span>
<button
className="xx-template-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse(template)
}}
>
使
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,219 @@
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 { 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
}
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
isFavorite,
onClose,
onToggleFavorite,
onUse,
onCopy,
}) => {
const segments = template.segments ?? []
const totalSegmentDuration = calcTotalSegmentDuration(segments)
return (
<div className="xx-template-modal-overlay" onClick={onClose}>
<div
className="xx-template-modal xx-template-modal-wide"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
</button>
{/* 预览区域 */}
<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">
<h3>{template.name}</h3>
<span
className="xx-template-modal-type-badge"
style={{
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
</span>
</div>
{/* 描述 */}
<p className="xx-template-modal-desc">{template.description}</p>
{/* 标签 */}
{(template.tags?.length ?? 0) > 0 && (
<div className="xx-template-modal-tags">
{template.tags!.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
)}
{/* 基本信息 */}
<Descriptions
column={2}
size="small"
className="xx-template-modal-desc-table"
items={[
{
key: "duration",
label: "目标时长",
children: formatDuration(template.estimated_duration ?? template.target_duration),
},
{
key: "clips",
label: "片段数量",
children: `${template.clip_count}`,
},
{
key: "ratio",
label: "视频比例",
children: template.aspect_ratio ?? "16:9",
},
{
key: "usage",
label: "使用次数",
children: `${template.usage_count ?? 0}`,
},
]}
/>
{/* 素材规则(片段配置) */}
{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>
</div>
</div>
</div>
)
}
@@ -0,0 +1,110 @@
import React from "react"
import { Pagination } from "antd"
import { InboxOutlined, LoadingOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
import { TemplateCard } from "./TemplateCard"
import type { TemplateItem } from "@/api/templates"
interface TemplateGridProps {
templates: TemplateItem[]
total: number
page: number
pageSize: number
isLoading: boolean
isError: boolean
errorMessage?: string
searchText: string
activeType: string
durationRange: string
onPageChange: (page: number) => void
onPreview: (template: TemplateItem) => void
onToggleFavorite: (id: string, e: React.MouseEvent) => void
onUse: (template: TemplateItem) => void
}
export const TemplateGrid: React.FC<TemplateGridProps> = ({
templates,
total,
page,
pageSize,
isLoading,
isError,
errorMessage,
searchText,
activeType,
durationRange,
onPageChange,
onPreview,
onToggleFavorite,
onUse,
}) => {
// Loading 状态
if (isLoading) {
return (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
<LoadingOutlined />
</div>
<h3>...</h3>
</div>
)
}
// Error 状态
if (isError) {
return (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
<ExclamationCircleOutlined />
</div>
<h3></h3>
<p>{errorMessage || "网络异常,请稍后重试"}</p>
</div>
)
}
// 空状态
if (templates.length === 0) {
const hasFilter = !!searchText || activeType !== "全部" || !!durationRange
return (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
<InboxOutlined />
</div>
<h3>{hasFilter ? "未找到匹配的模板" : "暂无模板"}</h3>
<p>{hasFilter ? "试试调整搜索条件或切换类型" : "点击上方「创建模板」开始创作"}</p>
</div>
)
}
return (
<>
<div className="xx-templates-grid">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.is_favorite ?? false}
onPreview={onPreview}
onToggleFavorite={onToggleFavorite}
onUse={onUse}
/>
))}
</div>
{/* 分页 */}
{total > pageSize && (
<div className="xx-templates-pagination">
<Pagination
current={page}
pageSize={pageSize}
total={total}
showSizeChanger={false}
showQuickJumper
showTotal={(t) => `${t} 个模板`}
onChange={onPageChange}
/>
</div>
)}
</>
)
}
@@ -0,0 +1,20 @@
import React from "react"
import { Button } from "antd"
interface TemplateHeaderProps {
onCreateClick: () => void
}
export const TemplateHeader: React.FC<TemplateHeaderProps> = ({ onCreateClick }) => {
return (
<div className="xx-templates-header">
<div className="xx-templates-header-text">
<h2></h2>
<p></p>
</div>
<Button type="primary" onClick={onCreateClick}>
+
</Button>
</div>
)
}
@@ -0,0 +1,63 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import type { EditTemplateType, DurationRange } from "../../types/templateLibrary"
import { TEMPLATE_TYPES, DURATION_OPTIONS } from "../../constants/templateLibrary"
interface TemplateToolbarProps {
searchText: string
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
activeType: EditTemplateType | "全部"
onTypeChange: (type: EditTemplateType | "全部") => void
durationRange: DurationRange
onDurationChange: (value: DurationRange) => void
}
export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
searchText,
onSearchChange,
activeType,
onTypeChange,
durationRange,
onDurationChange,
}) => {
return (
<div className="xx-templates-toolbar">
<div className="xx-templates-search">
<span className="xx-templates-search-icon">
<SearchOutlined />
</span>
<input
className="xx-templates-search-input"
type="text"
placeholder="搜索模板名称、描述或标签..."
value={searchText}
onChange={onSearchChange}
/>
</div>
<div className="xx-templates-categories">
{TEMPLATE_TYPES.map((cat) => (
<button
key={cat.type}
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
onClick={() => onTypeChange(cat.type)}
>
<span className="xx-templates-cat-icon">{cat.icon}</span>
{cat.label}
</button>
))}
</div>
{/* 时长筛选 */}
<div className="xx-templates-duration-filter">
{DURATION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
onClick={() => onDurationChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
@@ -0,0 +1,46 @@
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
export const TEMPLATE_TYPES: Array<{
type: EditTemplateType | "全部"
label: string
icon: string
color: string
}> = [
{ type: "全部", label: "全部", icon: "📋", color: "#6366f1" },
{ type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" },
{ type: "种草", label: "种草", icon: "🌱", color: "#10b981" },
{ type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" },
{ type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" },
{ type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" },
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
]
export const DURATION_OPTIONS: Array<{
value: DurationRange
label: string
}> = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
subtitle: "字幕",
null: "不限",
}
export const DEFAULT_PAGE_SIZE = 12
export const CATEGORY_GRADIENT_MAP: Record<string, string> = {
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
: "linear-gradient(135deg, #10b981, #059669)",
: "linear-gradient(135deg, #0ea5e9, #0284c7)",
: "linear-gradient(135deg, #f59e0b, #d97706)",
: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
}
export const DEFAULT_GRADIENT = "linear-gradient(135deg, #6366f1, #8b5cf6)"
@@ -0,0 +1,62 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { getTemplate, type TemplateItem } from "@/api/templates"
interface UseTemplateDetailProps {
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
export const useTemplateDetail = ({ onToggleFavorite, onUse, onCopy }: UseTemplateDetailProps) => {
const queryClient = useQueryClient()
/* 弹窗状态 */
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
/* 点击卡片 → 获取详情并展示弹窗 */
const handlePreview = useCallback(async (template: TemplateItem) => {
setDetailLoading(true)
setPreviewTemplate(template)
try {
const detail = await getTemplate(template.id)
setPreviewTemplate(detail)
} catch {
message.warning("模板详情加载失败,显示摘要信息")
} finally {
setDetailLoading(false)
}
}, [])
/* 关闭弹窗 */
const handleClose = useCallback(() => {
setPreviewTemplate(null)
setDetailLoading(false)
}, [])
/* 收藏切换(同时更新预览模板的状态) */
const handleToggleFavorite = useCallback(
(id: string) => {
onToggleFavorite(id)
/* 乐观更新详情弹窗的收藏状态 */
setPreviewTemplate((prev) =>
prev && prev.id === id ? { ...prev, is_favorite: !prev.is_favorite } : prev,
)
/* 刷新列表缓存 */
queryClient.invalidateQueries({ queryKey: ["templates"] })
},
[onToggleFavorite, queryClient],
)
return {
previewTemplate,
detailLoading,
handlePreview,
handleClose,
handleToggleFavorite,
handleUse: onUse,
handleCopy: onCopy,
}
}
@@ -0,0 +1,147 @@
import { useState, useMemo, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
getTemplates,
toggleFavoriteTemplate,
copyTemplate,
type TemplateItem,
type TemplateListParams,
} from "@/api/templates"
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
import { DEFAULT_PAGE_SIZE } from "../constants/templateLibrary"
export const useTemplateLibrary = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
/* 筛选状态 */
const [searchText, setSearchText] = useState("")
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部")
const [durationRange, setDurationRange] = useState<DurationRange>("")
const [page, setPage] = useState(1)
const [pageSize] = useState(DEFAULT_PAGE_SIZE)
/* 构建查询参数 */
const queryParams: TemplateListParams = useMemo(() => {
const params: TemplateListParams = {
page,
page_size: pageSize,
}
if (activeType !== "全部") params.category = activeType
if (searchText.trim()) params.keyword = searchText.trim()
if (durationRange) params.duration_range = durationRange
return params
}, [page, pageSize, activeType, searchText, durationRange])
/* 获取模板列表 */
const {
data: templateData,
isLoading,
isError,
error,
} = useQuery({
queryKey: ["templates", queryParams],
queryFn: () => getTemplates(queryParams),
staleTime: 30_000,
})
const templates = templateData?.items ?? []
const totalTemplates = templateData?.total ?? 0
/* 收藏 mutation */
const favMutation = useMutation({
mutationFn: toggleFavoriteTemplate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["templates"] })
},
})
/* 复制模板 mutation */
const copyMutation = useMutation({
mutationFn: copyTemplate,
onSuccess: (data) => {
message.success(`模板「${data.name}」已复制到「我的模板」`)
queryClient.invalidateQueries({ queryKey: ["templates"] })
},
onError: () => {
message.error("复制模板失败,请稍后重试")
},
})
/* 操作:切换收藏 */
const toggleFavorite = useCallback(
(id: string, e?: React.MouseEvent) => {
e?.stopPropagation()
favMutation.mutate(id)
},
[favMutation],
)
/* 操作:复制模板 */
const handleCopy = useCallback(
(template: TemplateItem) => {
copyMutation.mutate(template.id)
},
[copyMutation],
)
/* 操作:使用模板 → 跳转剪辑编辑器 */
const handleUse = useCallback(
(template: TemplateItem) => {
navigate(`/app/editing-planner?templateId=${template.id}`)
},
[navigate],
)
/* 操作:创建模板 */
const handleCreate = useCallback(() => {
navigate("/app/editing-planner")
}, [navigate])
/* 搜索 */
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value)
setPage(1)
}, [])
/* 切换分类 */
const handleCategoryChange = useCallback((type: EditTemplateType | "全部") => {
setActiveType(type)
setPage(1)
}, [])
/* 切换时长筛选 */
const handleDurationChange = useCallback((value: DurationRange) => {
setDurationRange(value)
setPage(1)
}, [])
return {
/* 状态 */
templates,
totalTemplates,
isLoading,
isError,
error,
searchText,
activeType,
durationRange,
page,
pageSize,
/* mutations */
favMutation,
copyMutation,
/* setters */
setPage,
/* handlers */
toggleFavorite,
handleCopy,
handleUse,
handleCreate,
handleSearchChange,
handleCategoryChange,
handleDurationChange,
}
}
@@ -0,0 +1,15 @@
/** 模板类型 */
export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"
/** 时长筛选值 */
export type DurationRange = "" | "short" | "medium" | "long"
/** 配置展示字段 */
export interface ConfigDisplayFields {
font_size?: string | number
font_family?: string
color?: string
position?: string
volume?: string | number
name?: string
}
@@ -0,0 +1,54 @@
import type { ConfigDisplayFields } from "../types/templateLibrary"
import {
TEMPLATE_TYPES,
CATEGORY_GRADIENT_MAP,
DEFAULT_GRADIENT,
MATERIAL_TYPE_LABELS,
} from "../constants/templateLibrary"
import type { TemplateSegment } from "@/api/templates"
/** 获取类型对应颜色 */
export const getTypeColor = (type: string): string => {
const found = TEMPLATE_TYPES.find((t) => t.type === type)
return found?.color ?? "#6366f1"
}
/** 根据 category 生成占位渐变色 */
export const gradientForCategory = (category: string): string => {
return CATEGORY_GRADIENT_MAP[category] ?? DEFAULT_GRADIENT
}
/** 格式化时长 */
export const formatDuration = (seconds: number | undefined | null): string => {
if (!seconds || seconds <= 0) return "0秒"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
if (m === 0) return `${s}`
return `${m}${s > 0 ? `${s}` : ""}`
}
/** 格式化配置对象为可读文本 */
export const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认"
const c = config as ConfigDisplayFields
const parts: string[] = []
if (c.font_size) parts.push(`字号: ${c.font_size}`)
if (c.font_family) parts.push(`字体: ${c.font_family}`)
if (c.color) parts.push(`颜色: ${c.color}`)
if (c.position) parts.push(`位置: ${c.position}`)
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`)
if (c.name) parts.push(String(c.name))
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config)
}
/** 获取素材类型标签文本 */
export const getMaterialTypeLabel = (materialType: string | null | undefined): string => {
if (!materialType) return "不限"
return MATERIAL_TYPE_LABELS[materialType] ?? materialType
}
/** 计算片段总时长(取每个片段 min/max 的平均值) */
export const calcTotalSegmentDuration = (segments: TemplateSegment[]): number => {
return segments.reduce((sum, s) => sum + (s.duration_min + s.duration_max) / 2, 0)
}
@@ -115,6 +115,16 @@ vi.mock("@/api/templates", () => ({
vi.mock("@/pages/templates/TemplateLibrary.css", () => ({})) vi.mock("@/pages/templates/TemplateLibrary.css", () => ({}))
import TemplateLibrary from "@/pages/templates/TemplateLibrary" import TemplateLibrary from "@/pages/templates/TemplateLibrary"
import "@/pages/templates/types/templateLibrary"
import "@/pages/templates/constants/templateLibrary"
import "@/pages/templates/utils/templateLibrary"
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/TemplateHeader"
import "@/pages/templates/components/template-library/TemplateToolbar"
import "@/pages/templates/components/template-library/TemplateGrid"
describe("TemplateLibrary Page", () => { describe("TemplateLibrary Page", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
+21 -221
View File
@@ -2,129 +2,33 @@
支持将指定颜色(默认绿色)变为透明,可用于虚拟背景、画中画背景替换等场景。 支持将指定颜色(默认绿色)变为透明,可用于虚拟背景、画中画背景替换等场景。
使用方式: 注:核心领域模型已抽离到 packages/domain/chroma_key_config.py
config = ChromaKeyConfig(key_color="#00FF00", similarity=0.3, blend=0.1) 本模块保留薄包装层,确保向后兼容。
engine = ChromaKeyEngine(config)
filter_str = engine.build_filter(input_label, output_label)
# 结果: [in]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[out]
降级策略:
- 参数越界自动钳制
- 素材格式不支持时跳过(调用方捕获异常)
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from dataclasses import dataclass
from typing import Optional from typing import Optional
from packages.domain.chroma_key_config import (
CHROMA_KEY_PRESETS,
ChromaKeyConfig,
apply_chroma_key_if_needed,
)
from packages.domain.chroma_key_config import ( # noqa: F401 — 向后兼容
build_chromakey_filter as _build_chromakey_filter_base,
)
from packages.domain.chroma_key_config import build_colorkey_filter as _build_colorkey_filter_base
from packages.domain.chroma_key_config import normalize_color as _normalize_color_base
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 配置模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ChromaKeyConfig:
"""绿幕抠像配置。
Attributes:
enabled: 是否启用抠像
key_color: 要抠除的颜色,支持 hex 格式(如 "#00FF00")或颜色名
similarity: 颜色相似度阈值 0.01~1.0,值越大抠除范围越大
blend: 边缘平滑/混合度 0.0~1.0,值越大边缘越柔和
spill_suppress: 溢色抑制 0.0~1.0,减少边缘的绿幕反光
"""
enabled: bool = False
key_color: str = "#00FF00"
similarity: float = 0.3
blend: float = 0.1
spill_suppress: float = 0.0
@classmethod
def from_dict(cls, data: dict | None) -> "ChromaKeyConfig":
"""从字典解析配置,参数越界自动钳制。"""
if not data or not data.get("enabled", False):
return cls(enabled=False)
key_color = str(data.get("key_color", "#00FF00")).strip()
def _safe_float(val, default):
try:
return float(val)
except (TypeError, ValueError):
return default
similarity = _safe_float(data.get("similarity", 0.3), 0.3)
blend = _safe_float(data.get("blend", 0.1), 0.1)
spill_suppress = _safe_float(data.get("spill_suppress", 0.0), 0.0)
# 钳制到合法范围
similarity = max(0.01, min(1.0, similarity))
blend = max(0.0, min(1.0, blend))
spill_suppress = max(0.0, min(1.0, spill_suppress))
return cls(
enabled=True,
key_color=key_color,
similarity=similarity,
blend=blend,
spill_suppress=spill_suppress,
)
def has_effect(self) -> bool:
"""判断是否有实际抠像效果。"""
return self.enabled and self.similarity > 0
# ── 预设配置 ──────────────────────────────────────────────────────────────────
# 常见绿幕/蓝幕预设
CHROMA_KEY_PRESETS = {
"green_screen": {
"key_color": "#00FF00",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.5,
},
"blue_screen": {
"key_color": "#0000FF",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.5,
},
"red_screen": {
"key_color": "#FF0000",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.0,
},
"precise_green": {
"key_color": "#00FF00",
"similarity": 0.2,
"blend": 0.05,
"spill_suppress": 0.3,
},
"soft_green": {
"key_color": "#00FF00",
"similarity": 0.45,
"blend": 0.2,
"spill_suppress": 0.5,
},
}
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
class ChromaKeyEngine: class ChromaKeyEngine:
"""绿幕抠像引擎 """绿幕抠像引擎.
基于 FFmpeg colorkey 滤镜实现,将指定颜色变为透明 薄包装层,实际逻辑委托给 packages.domain.chroma_key_config
适用于绿幕/蓝幕视频的背景去除,配合画中画或 overlay 实现虚拟背景。
""" """
def __init__(self, config: ChromaKeyConfig): def __init__(self, config: ChromaKeyConfig):
@@ -132,117 +36,13 @@ class ChromaKeyEngine:
@staticmethod @staticmethod
def _normalize_color(color_str: str) -> str: def _normalize_color(color_str: str) -> str:
"""将颜色字符串转为 FFmpeg colorkey 接受的格式 """将颜色字符串转为 FFmpeg colorkey 接受的格式."""
return _normalize_color_base(color_str)
支持:
- "#RRGGBB" / "#RRGGBBAA" → 0xRRGGBB
- "0xRRGGBB" → 直接使用
- 颜色名(green/blue/red/black/white 等)→ 直接透传
"""
color = color_str.strip()
# hex 格式
hex_match = re.match(r"^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$", color)
if hex_match:
return f"0x{hex_match.group(1).upper()}"
# 已经是 0x 格式
if color.lower().startswith("0x"):
return color.upper()
# 颜色名直接透传(FFmpeg 支持常见颜色名)
return color
def build_filter(self, input_label: str, output_label: str) -> str: def build_filter(self, input_label: str, output_label: str) -> str:
"""构建 colorkey 滤镜字符串 """构建 colorkey 滤镜字符串."""
return _build_colorkey_filter_base(self.config, input_label, output_label)
Args:
input_label: 输入标签,如 "[0:v]""[v0]"
output_label: 输出标签,如 "[ck0]"
Returns:
FFmpeg 滤镜字符串,如 "[v0]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[ck0]"
Raises:
ValueError: 配置无效时抛出(调用方应捕获并降级)
"""
if not self.config.has_effect():
# 无效果,直接直通
return f"{input_label}copy{output_label}"
color = self._normalize_color(self.config.key_color)
similarity = self.config.similarity
blend = self.config.blend
# 基础 colorkey 滤镜
parts = [f"colorkey=color={color}:similarity={similarity}:blend={blend}"]
# 溢色抑制(通过 colorchannelmixer 降低绿色通道增益)
if self.config.spill_suppress > 0:
# 降低绿通道增益,减少绿幕反光溢出
spill = self.config.spill_suppress
# 绿通道增益 = 1 - spill_factor
g_gain = max(0.3, 1.0 - spill * 0.7)
# 同时稍微提升红和蓝来补偿色偏
r_gain = 1.0 + spill * 0.15
b_gain = 1.0 + spill * 0.15
parts.append(f"colorchannelmixer=" f"rr={r_gain}:" f"gg={g_gain}:" f"bb={b_gain}:" f"aa=1")
filter_str = f"{input_label}{','.join(parts)}{output_label}"
return filter_str
def build_filter_chromakey(self, input_label: str, output_label: str) -> str: def build_filter_chromakey(self, input_label: str, output_label: str) -> str:
"""使用 chromakey 滤镜(更高级的版本,支持更多参数) """使用 chromakey 滤镜(更高级的版本,支持更多参数)."""
return _build_chromakey_filter_base(self.config, input_label, output_label)
注意:并非所有 FFmpeg 版本都支持 chromakey 滤镜,
优先使用 colorkey(兼容性更好)。
Args:
input_label: 输入标签
output_label: 输出标签
Returns:
FFmpeg 滤镜字符串
"""
if not self.config.has_effect():
return f"{input_label}copy{output_label}"
color = self._normalize_color(self.config.key_color)
similarity = self.config.similarity
blend = self.config.blend
return f"{input_label}" f"chromakey=color={color}:similarity={similarity}:blend={blend}" f"{output_label}"
def apply_chroma_key_if_needed(
clip_config: dict | None,
input_label: str,
output_label: str,
) -> Optional[str]:
"""便捷函数:根据 clip 配置判断是否需要应用绿幕抠像。
Args:
clip_config: clip 的 config 字典
input_label: 输入标签
output_label: 输出标签
Returns:
滤镜字符串,不需要抠像时返回 None
"""
if not clip_config:
return None
chroma_key_data = clip_config.get("chroma_key")
if not chroma_key_data:
return None
try:
config = ChromaKeyConfig.from_dict(chroma_key_data)
if not config.has_effect():
return None
engine = ChromaKeyEngine(config)
return engine.build_filter(input_label, output_label)
except Exception as e:
logger.warning("[chroma-key] 应用抠像失败,跳过: %s", e)
return None
@@ -10,233 +10,28 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__) from packages.domain.color_grade_config import ( # noqa: F401 — 向后兼容
DEFAULT_PARAMS,
PARAM_RANGES,
# ── 预设滤镜包 ──────────────────────────────────────────────────────────────── PRESET_BW,
PRESET_CINEMA,
# 预设名称常量 PRESET_COOL,
PRESET_FRESH = "fresh" # 清新 PRESET_DISPLAY_NAMES,
PRESET_JAPANESE = "japanese" # 日系 PRESET_FILM,
PRESET_VINTAGE = "vintage" # 复古
PRESET_CINEMA = "cinema" # 电影
PRESET_FILM = "film" # 胶片
PRESET_BW = "black_white" # 黑白
PRESET_WARM = "warm" # 暖色
PRESET_COOL = "cool" # 冷色
VALID_PRESETS = {
PRESET_FRESH, PRESET_FRESH,
PRESET_JAPANESE, PRESET_JAPANESE,
PRESET_PARAMS,
PRESET_VINTAGE, PRESET_VINTAGE,
PRESET_CINEMA,
PRESET_FILM,
PRESET_BW,
PRESET_WARM, PRESET_WARM,
PRESET_COOL, VALID_PRESETS,
} ColorGradeConfig,
clamp_param,
get_preset_names,
get_preset_params,
)
# 预设名称 → 中文显示名 logger = logging.getLogger(__name__)
PRESET_DISPLAY_NAMES = {
PRESET_FRESH: "清新",
PRESET_JAPANESE: "日系",
PRESET_VINTAGE: "复古",
PRESET_CINEMA: "电影",
PRESET_FILM: "胶片",
PRESET_BW: "黑白",
PRESET_WARM: "暖色",
PRESET_COOL: "冷色",
}
# 预设参数配置
# 每个预设包含:brightness, contrast, saturation, temperature, hue
# 取值范围:brightness/contrast/temperature -100~100, saturation 0~200, hue -180~180
PRESET_PARAMS: dict[str, dict[str, float]] = {
PRESET_FRESH: {
# 清新:提亮、高饱和、偏冷、微微调
"brightness": 8,
"contrast": 10,
"saturation": 120,
"temperature": -8,
"hue": 5,
},
PRESET_JAPANESE: {
# 日系:低对比、低饱和、偏暖、偏黄绿
"brightness": 12,
"contrast": -15,
"saturation": 70,
"temperature": 10,
"hue": -5,
},
PRESET_VINTAGE: {
# 复古:低饱和、偏黄、对比度适中、偏暖
"brightness": -5,
"contrast": 5,
"saturation": 60,
"temperature": 25,
"hue": -8,
},
PRESET_CINEMA: {
# 电影:高对比、低饱和、偏冷蓝、暗角感
"brightness": -8,
"contrast": 20,
"saturation": 75,
"temperature": -15,
"hue": -3,
},
PRESET_FILM: {
# 胶片:中对比、饱和适中、偏暖、颗粒感(这里只用调色模拟)
"brightness": -3,
"contrast": 12,
"saturation": 95,
"temperature": 15,
"hue": -2,
},
PRESET_BW: {
# 黑白:饱和度为0,对比度略高
"brightness": 0,
"contrast": 15,
"saturation": 0,
"temperature": 0,
"hue": 0,
},
PRESET_WARM: {
# 暖色:高色温、偏红黄
"brightness": 5,
"contrast": 8,
"saturation": 110,
"temperature": 30,
"hue": -5,
},
PRESET_COOL: {
# 冷色:低色温、偏蓝青
"brightness": 3,
"contrast": 8,
"saturation": 105,
"temperature": -25,
"hue": 8,
},
}
# ── 参数范围 ──────────────────────────────────────────────────────────────────
PARAM_RANGES = {
"brightness": (-100.0, 100.0),
"contrast": (-100.0, 100.0),
"saturation": (0.0, 200.0),
"temperature": (-100.0, 100.0),
"hue": (-180.0, 180.0),
}
# 默认值(零调整)
DEFAULT_PARAMS = {
"brightness": 0.0,
"contrast": 0.0,
"saturation": 100.0,
"temperature": 0.0,
"hue": 0.0,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ColorGradeConfig:
"""色彩调色配置.
优先级:自定义参数 > 预设参数
即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖
"""
enabled: bool = False
preset: str = "" # 预设名称,空表示不使用预设
# 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值)
brightness: float | None = None
contrast: float | None = None
saturation: float | None = None
temperature: float | None = None
hue: float | None = None
def resolve_params(self) -> dict[str, float]:
"""解析最终调色参数(预设 + 自定义覆盖 + 边界钳制).
Returns:
包含 brightness, contrast, saturation, temperature, hue 的参数字典
"""
# 1. 从默认值开始
params = dict(DEFAULT_PARAMS)
# 2. 应用预设
if self.preset and self.preset in PRESET_PARAMS:
params.update(PRESET_PARAMS[self.preset])
# 3. 应用自定义覆盖
if self.brightness is not None:
params["brightness"] = self.brightness
if self.contrast is not None:
params["contrast"] = self.contrast
if self.saturation is not None:
params["saturation"] = self.saturation
if self.temperature is not None:
params["temperature"] = self.temperature
if self.hue is not None:
params["hue"] = self.hue
# 4. 边界钳制
for key, (min_val, max_val) in PARAM_RANGES.items():
params[key] = max(min_val, min(max_val, params[key]))
return params
def has_effect(self) -> bool:
"""判断是否有实际调色效果(所有参数都是默认值则无效果).
用于优化:无效果时跳过滤镜,不浪费性能。
"""
params = self.resolve_params()
for key, default in DEFAULT_PARAMS.items():
if abs(params[key] - default) > 0.001:
return True
return False
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
preset = data.get("preset", "")
if preset and preset not in VALID_PRESETS:
logger.warning("未知的调色预设: %s,忽略预设", preset)
preset = ""
def _get_float(key: str) -> float | None:
val = data.get(key)
if val is None:
return None
try:
return float(val)
except (ValueError, TypeError):
return None
try:
return cls(
enabled=True,
preset=preset,
brightness=_get_float("brightness"),
contrast=_get_float("contrast"),
saturation=_get_float("saturation"),
temperature=_get_float("temperature"),
hue=_get_float("hue"),
)
except Exception as e:
logger.warning("调色配置解析失败: %s,使用默认配置", e)
return cls(enabled=False)
# ── 调色引擎 ────────────────────────────────────────────────────────────────── # ── 调色引擎 ──────────────────────────────────────────────────────────────────
+8 -121
View File
@@ -18,135 +18,22 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
from packages.domain.video_concat import ( # noqa: F401 向后兼容导出
ALLOWED_VIDEO_EXTENSIONS,
CONCAT_DEMUXER_REQUIRED_PARAMS,
MAX_CONCAT_SEGMENTS,
ConcatConfig,
ConcatSegment,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
# concat demuxer 要求一致的参数列表
CONCAT_DEMUXER_REQUIRED_PARAMS = [
"codec_name", # 视频编码
"width", # 宽度
"height", # 高度
"r_frame_rate", # 帧率
"pix_fmt", # 像素格式
"sample_rate", # 音频采样率
"channels", # 音频声道数
"audio_codec", # 音频编码
]
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
@dataclass
class ConcatSegment:
"""单个拼接片段."""
video_path: str # 视频文件路径
start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
has_audio: bool = True # 是否包含音频
@classmethod
def from_dict(cls, seg: dict) -> "ConcatSegment":
"""从字典创建拼接片段,带安全类型转换."""
try:
start_time = max(0.0, float(seg.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(seg.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
video_path=str(seg.get("video_path", "")),
start_time=start_time,
duration=duration,
has_audio=bool(seg.get("has_audio", True)),
)
@dataclass
class ConcatConfig:
"""视频拼接配置."""
segments: list[ConcatSegment] = field(default_factory=list)
output_width: int = 0 # 输出宽度(0=自动取第一段)
output_height: int = 0 # 输出高度(0=自动取第一段)
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
force_reencode: bool = False # 强制重新编码(不用 stream copy
transition: str = "none" # 转场效果(none/crossfade- 预留
transition_duration: float = 0.3 # 转场时长
@classmethod
def from_config_dict(cls, config: dict | None) -> "ConcatConfig":
"""从配置字典创建 ConcatConfig."""
if not config or not isinstance(config, dict):
return cls()
segments_raw = config.get("segments", [])
segments: list[ConcatSegment] = []
if isinstance(segments_raw, list):
for s in segments_raw:
if isinstance(s, dict) and s.get("video_path"):
try:
seg = ConcatSegment.from_dict(s)
if seg.video_path:
segments.append(seg)
except Exception:
logger.warning("[concat] skip invalid segment: %s", s)
continue
try:
output_width = max(0, int(config.get("output_width", 0)))
except (TypeError, ValueError):
output_width = 0
try:
output_height = max(0, int(config.get("output_height", 0)))
except (TypeError, ValueError):
output_height = 0
try:
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
except (TypeError, ValueError):
output_fps = 0.0
return cls(
segments=segments,
output_width=output_width,
output_height=output_height,
output_fps=output_fps,
force_reencode=bool(config.get("force_reencode", False)),
transition=str(config.get("transition", "none")),
transition_duration=max(0.1, float(config.get("transition_duration", 0.3))),
)
@property
def has_effect(self) -> bool:
"""是否有有效片段需要拼接."""
return len([s for s in self.segments if s.video_path]) >= 2
@property
def total_segments(self) -> int:
"""有效片段数量."""
return len([s for s in self.segments if s.video_path])
# ── 路径安全校验 ──────────────────────────────────────────────────────────── # ── 路径安全校验 ────────────────────────────────────────────────────────────
+38 -144
View File
@@ -20,6 +20,17 @@ from shared.ffmpeg_utils import ( # noqa: F401
run_ffmpeg, run_ffmpeg,
) )
# xfade 转场纯逻辑已抽离到 domain 层,这里 re-export 保持向后兼容
from packages.domain.xfade_builder import DEFAULT_TRANSITION_DURATION as _default_transition_duration_base # noqa: F401
from packages.domain.xfade_builder import (
SUPPORTED_TRANSITIONS,
XFADE_TRANSITION_MAP,
XFade_TRANSITION_NAMES,
)
from packages.domain.xfade_builder import build_xfade_filter_chain as _build_xfade_filter_chain_base
from packages.domain.xfade_builder import chain_filters as _chain_filters_base
from packages.domain.xfade_builder import resolve_xfade_transition as _resolve_xfade_transition_base
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 常量(Worker 层业务相关) ──────────────────────────────────────────────── # ── 常量(Worker 层业务相关) ────────────────────────────────────────────────
@@ -28,43 +39,34 @@ DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720 DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25 DEFAULT_FPS = 25
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称 # 向后兼容:DEFAULT_TRANSITION_DURATION 从 domain 层导出
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容) DEFAULT_TRANSITION_DURATION = _default_transition_duration_base
# "cut" 为特殊值:硬切,不使用 xfade(由调用方特殊处理)
XFADE_TRANSITION_MAP: dict[str, str] = {
# 基础
"fade": "fade",
"dissolve": "dissolve",
"crossfade": "dissolve",
"crossdissolve": "dissolve",
# 滑入系列
"slideleft": "slideleft",
"slide_left": "slideleft",
"slideright": "slideright",
"slide_right": "slideright",
"slideup": "slideup",
"slide_up": "slideup",
"slidedown": "slidedown",
"slide_down": "slidedown",
"slide": "slideleft", # 默认向左滑
# 缩放
"zoom": "zoomin",
"zoomin": "zoomin",
"zoomout": "zoomout",
# 擦除系列
"wipe": "wipeleft", # 默认向左擦
"wipeleft": "wipeleft",
"wiperight": "wiperight",
"wipeup": "wipeup",
"wipedown": "wipedown",
# 特殊效果
"circlecrop": "circlecrop",
"circle": "circlecrop",
"rectcrop": "rectcrop",
"rect": "rectcrop",
}
DEFAULT_TRANSITION_DURATION = 0.5
# 向后兼容:薄包装函数
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
return _chain_filters_base(filters, output_label, input_label=input_label)
def resolve_xfade_transition(transition_name: Any) -> str:
return _resolve_xfade_transition_base(transition_name)
def build_xfade_filter_chain(
clip_durations: list[float],
clip_video_labels: list[str],
transitions: list[str],
*,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
output_label: str = "outv",
) -> tuple[str, float]:
return _build_xfade_filter_chain_base(
clip_durations,
clip_video_labels,
transitions,
transition_duration=transition_duration,
output_label=output_label,
)
# ── FFprobe 探测 ────────────────────────────────────────────────────────────── # ── FFprobe 探测 ──────────────────────────────────────────────────────────────
@@ -304,111 +306,3 @@ def normalize_video(
] ]
run_ffmpeg(command) run_ffmpeg(command)
return {"width": width, "height": height, "path": output_path} return {"width": width, "height": height, "path": output_path}
# ── xfade / concat 滤镜构建 ──────────────────────────────────────────────────
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串。
例:chain_filters(["scale=1280:720", "fps=25"], "v0")
"[0:v]scale=1280:720,fps=25[v0]"
"""
filter_body = ",".join(filters)
return f"[{input_label}]{filter_body}[{output_label}]"
def resolve_xfade_transition(transition_name: str) -> str:
"""将转场效果名称映射为 FFmpeg xfade transition 名称。
支持 TransitionEffect 枚举值和字符串名称,未知值回退到 "fade"
"""
# 兼容 TransitionEffect 枚举(有 .value 属性)
if hasattr(transition_name, "value"):
transition_name = transition_name.value
return XFADE_TRANSITION_MAP.get(transition_name, "fade")
def build_xfade_filter_chain(
clip_durations: list[float],
clip_video_labels: list[str],
transitions: list[str],
*,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链。
对每步 xfade 自动钳制 transition duration,确保
``offset + td ≤ first_input_duration``,避免 FFmpeg exit 234。
Args:
clip_durations: 每个片段的时长(必须与 trim 后的实际时长一致)
clip_video_labels: 每个片段的视频流标签(如 "v0", "v1"
transitions: 每个片段对应的转场效果(第一个片段的转场被忽略)
transition_duration: 转场时长(秒)
output_label: 最终输出标签
Returns:
(filter_string, estimated_total_duration)
"""
n = len(clip_durations)
parts: list[str] = []
if n == 0:
return "", 0.0
if n == 1:
parts.append(f"[{clip_video_labels[0]}]copy[{output_label}]")
return ";".join(parts), clip_durations[0]
# xfade 链 — 每步动态钳制 td,防止 offset + td > first_input_duration
cumulative = 0.0
prev_label = clip_video_labels[0]
total_transition = 0.0 # 累计已使用的转场时长
for i in range(1, n):
cumulative += clip_durations[i - 1]
# 当前 xfade 的第一个输入时长
if i == 1:
first_input_dur = clip_durations[0]
else:
first_input_dur = cumulative - total_transition
# 原始 offset 计算
offset = max(0.0, cumulative - transition_duration * i)
# 安全钳制:offset + td 不能超过第一个输入的时长
available = max(0.0, first_input_dur - offset)
safe_td = min(transition_duration, available)
# 同时不能超过剩余总时长
remaining = max(0.0, sum(clip_durations) - cumulative)
safe_td = min(safe_td, remaining)
# 同时不能超过当前第二个输入(单个片段)的时长
safe_td = min(safe_td, clip_durations[i])
safe_td = max(0.001, safe_td) # 至少 1ms,避免 td=0
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = resolve_xfade_transition(transition)
if i == n - 1:
out_label = output_label
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_video_labels[i]}]"
f"xfade=transition={xfade_transition}"
f":duration={safe_td:.3f}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
total_transition += safe_td
# 总时长减去转场重叠部分
total_duration = sum(clip_durations) - total_transition
return ";".join(parts), max(0.0, total_duration)
@@ -11,129 +11,23 @@ from __future__ import annotations
import logging import logging
import subprocess import subprocess
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
from packages.domain.intro_outro_config import ( # noqa: F401 — 向后兼容
INTRO_OUTRO_TYPE_FOLLOW,
INTRO_OUTRO_TYPE_NONE,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_VIDEO,
TRANSITION_FADE,
IntroOutroConfig,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass # ── 片头片尾引擎 ──────────────────────────────────────────────────────────────
class IntroOutroConfig:
"""片头片尾配置.
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
"""
enabled: bool = False
# 片头
intro_type: str = "none" # none | video | text
intro_video_path: str = "" # 视频片段路径
intro_duration: float = 3.0 # 片头时长(秒)
# 文字片头配置
intro_background: str = "#000000" # 背景色
intro_title: str = ""
intro_subtitle: str = ""
intro_title_color: str = "white"
intro_title_size: int = 48
intro_subtitle_color: str = "gray"
intro_subtitle_size: int = 24
# 片尾
outro_type: str = "none" # none | video | text | follow
outro_video_path: str = "" # 视频片段路径
outro_duration: float = 3.0 # 片尾时长(秒)
# 文字片尾配置
outro_background: str = "#000000"
outro_title: str = "感谢观看"
outro_subtitle: str = "点赞关注不迷路"
outro_title_color: str = "white"
outro_title_size: int = 48
outro_subtitle_color: str = "gray"
outro_subtitle_size: int = 24
# 转场
transition_effect: str = "fade"
transition_duration: float = 0.5
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig:
"""从字典构造."""
if not data:
return cls()
enabled = data.get("enabled", False)
if not enabled:
return cls()
intro = data.get("intro", {}) or {}
outro = data.get("outro", {}) or {}
return cls(
enabled=True,
# 片头
intro_type=str(intro.get("type", "none")),
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
intro_duration=float(intro.get("duration", 3.0)),
intro_background=str(intro.get("background", "#000000")),
intro_title=str(intro.get("title", "") or ""),
intro_subtitle=str(intro.get("subtitle", "") or ""),
intro_title_color=str(intro.get("title_color", "white")),
intro_title_size=int(intro.get("title_size", 48)),
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
intro_subtitle_size=int(intro.get("subtitle_size", 24)),
# 片尾
outro_type=str(outro.get("type", "none")),
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
outro_duration=float(outro.get("duration", 3.0)),
outro_background=str(outro.get("background", "#000000")),
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
outro_title_color=str(outro.get("title_color", "white")),
outro_title_size=int(outro.get("title_size", 48)),
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
outro_subtitle_size=int(outro.get("subtitle_size", 24)),
# 转场
transition_effect=str(data.get("transition", "fade")),
transition_duration=float(data.get("transition_duration", 0.5)),
)
@property
def has_intro(self) -> bool:
"""是否有片头."""
return self.enabled and self.intro_type in ("video", "text")
@property
def has_outro(self) -> bool:
"""是否有片尾."""
return self.enabled and self.outro_type in ("video", "text", "follow")
def validate(self) -> tuple[bool, str]:
"""校验配置."""
if not self.enabled:
return True, ""
if self.intro_type == "video" and not self.intro_video_path:
return False, "视频片头缺少 video_path"
if self.intro_type == "text" and not self.intro_title:
return False, "文字片头缺少 title"
if self.outro_type == "video" and not self.outro_video_path:
return False, "视频片尾缺少 video_path"
if self.outro_type in ("text", "follow") and not self.outro_title:
return False, "文字片尾缺少 title"
if self.intro_duration <= 0:
return False, "片头时长必须大于 0"
if self.outro_duration <= 0:
return False, "片尾时长必须大于 0"
return True, ""
class IntroOutroEngine: class IntroOutroEngine:
+13 -131
View File
@@ -16,152 +16,34 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
from packages.domain.audio_track_config import ( # noqa: F401 — 向后兼容
ALLOWED_AUDIO_EXTENSIONS,
DEFAULT_VOLUMES,
MAX_AUDIO_TRACKS,
TRACK_TYPE_AMBIENT,
TRACK_TYPE_BGM,
TRACK_TYPE_MAIN,
TRACK_TYPE_SFX,
TRACK_TYPE_VOICEOVER,
AudioTrack,
MultiTrackMixConfig,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from video_processing.render_audio import RenderContext from video_processing.render_audio import RenderContext
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
TRACK_TYPE_BGM = "bgm" # 背景音乐
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
TRACK_TYPE_SFX = "sfx" # 音效
TRACK_TYPE_AMBIENT = "ambient" # 环境音
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
# 各轨道默认音量(相对主音频)
DEFAULT_VOLUMES = {
TRACK_TYPE_MAIN: 1.0,
TRACK_TYPE_BGM: 0.3,
TRACK_TYPE_VOICEOVER: 1.0,
TRACK_TYPE_SFX: 0.7,
TRACK_TYPE_AMBIENT: 0.2,
}
@dataclass
class AudioTrack:
"""单条音频轨道配置."""
track_id: str # 轨道唯一标识
track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient
audio_path: str # 音频文件路径
volume: float = 1.0 # 音量 0.0 ~ 2.0
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
duration: float = 0.0 # 持续时长(0表示到文件末尾)
enabled: bool = True # 是否启用
@classmethod
def from_dict(cls, track: dict) -> "AudioTrack":
"""从字典创建 AudioTrack,带安全类型转换."""
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
try:
volume = float(track.get("volume", default_vol))
except (TypeError, ValueError):
volume = default_vol
volume = max(0.0, min(2.0, volume))
try:
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
except (TypeError, ValueError):
fade_in = 0.0
try:
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
except (TypeError, ValueError):
fade_out = 0.0
try:
start_time = max(0.0, float(track.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(track.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
track_id=str(track.get("track_id", "")),
track_type=track_type,
audio_path=str(track.get("audio_path", "")),
volume=volume,
fade_in=fade_in,
fade_out=fade_out,
start_time=start_time,
duration=duration,
enabled=bool(track.get("enabled", True)),
)
@dataclass
class MultiTrackMixConfig:
"""多轨道混音配置."""
tracks: list[AudioTrack] = field(default_factory=list)
master_volume: float = 1.0 # 主输出音量
normalize: bool = True # 是否自动归一化补偿
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
@classmethod
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
"""从 plan.config.audio_tracks 字典创建配置."""
if not config or not isinstance(config, dict):
return cls()
tracks_raw = config.get("tracks", [])
tracks: list[AudioTrack] = []
if isinstance(tracks_raw, list):
for t in tracks_raw:
if isinstance(t, dict) and t.get("audio_path"):
try:
track = AudioTrack.from_dict(t)
if track.enabled and track.audio_path:
tracks.append(track)
except Exception:
logger.warning("[multi-track] skip invalid track config: %s", t)
continue
try:
master_volume = float(config.get("master_volume", 1.0))
master_volume = max(0.0, min(2.0, master_volume))
except (TypeError, ValueError):
master_volume = 1.0
return cls(
tracks=tracks,
master_volume=master_volume,
normalize=bool(config.get("normalize", True)),
max_output_volume=float(config.get("max_output_volume", 1.5)),
)
@property
def has_effect(self) -> bool:
"""是否有有效轨道需要混音."""
return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0
# ── 路径安全校验 ──────────────────────────────────────────────────────────── # ── 路径安全校验 ────────────────────────────────────────────────────────────
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
def _validate_audio_path(audio_path: str, work_dir: Path) -> None: def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
"""校验音频文件路径安全性. """校验音频文件路径安全性.
@@ -2,126 +2,28 @@
支持对音频进行背景噪音消除、人声增强,适用于语音录制、采访等场景。 支持对音频进行背景噪音消除、人声增强,适用于语音录制、采访等场景。
使用方式: 领域模型已抽离至 packages/domain/noise_reduction_config.py,本模块保留薄包装以维持向后兼容。
config = NoiseReductionConfig(level="medium")
engine = NoiseReductionEngine(config)
filter_str = engine.build_filter(input_label, output_label)
# 结果: [0:a]afftdn=nf=-25[out]
降级策略:
- 参数越界自动钳制
- FFmpeg 不支持 afftdn 时,调用方可捕获异常并跳过
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from enum import Enum from packages.domain.noise_reduction_config import (
from typing import Optional NoiseReductionConfig,
NoiseReductionLevel,
)
from packages.domain.noise_reduction_config import (
apply_noise_reduction_if_needed as _apply_noise_reduction_if_needed_base,
)
from packages.domain.noise_reduction_config import build_afftdn_filter as _build_afftdn_filter_base # noqa: F401 — 向后兼容
from packages.domain.noise_reduction_config import build_arnndn_filter as _build_arnndn_filter_base
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 降噪等级 ──────────────────────────────────────────────────────────────────
class NoiseReductionLevel(str, Enum):
"""降噪等级预设。"""
LOW = "low" # 轻度降噪,保留细节,适合轻微背景噪音
MEDIUM = "medium" # 中度降噪,平衡效果和音质
HIGH = "high" # 高度降噪,适合嘈杂环境,可能轻微影响音质
CUSTOM = "custom" # 自定义参数
# 各等级对应的降噪参数(afftdn 的 noise floor,单位 dB
# 值越大(越接近 0),降噪越强;值越小(越负),降噪越弱
_LEVEL_PARAMS = {
NoiseReductionLevel.LOW: {
"nf": -35, # 噪音阈值(dB),越负越保守
"tn": -10, # 噪音频谱平滑度
"tr": 50, # 时间分辨率(ms
},
NoiseReductionLevel.MEDIUM: {
"nf": -25,
"tn": -10,
"tr": 50,
},
NoiseReductionLevel.HIGH: {
"nf": -15,
"tn": -5,
"tr": 30,
},
}
# ── 配置模型 ──────────────────────────────────────────────────────────────────
@dataclass
class NoiseReductionConfig:
"""音频降噪配置。
Attributes:
enabled: 是否启用降噪
level: 降噪等级 low/medium/high/custom
noise_floor: 自定义噪音阈值(dB),仅 level=custom 时有效,范围 -60 ~ -5
voice_enhance: 是否启用人声增强
output_format: 输出格式描述(内部使用)
"""
enabled: bool = False
level: NoiseReductionLevel = NoiseReductionLevel.MEDIUM
noise_floor: float = -25.0 # dB
voice_enhance: bool = False
@classmethod
def from_dict(cls, data: dict | None) -> "NoiseReductionConfig":
"""从字典解析配置,参数越界自动钳制。"""
if not data or not data.get("enabled", False):
return cls(enabled=False)
level_str = str(data.get("level", "medium")).lower()
try:
level = NoiseReductionLevel(level_str)
except ValueError:
level = NoiseReductionLevel.MEDIUM
try:
noise_floor = float(data.get("noise_floor", -25.0))
except (TypeError, ValueError):
noise_floor = -25.0
voice_enhance = bool(data.get("voice_enhance", False))
# 钳制到合法范围
noise_floor = max(-60.0, min(-5.0, noise_floor))
return cls(
enabled=True,
level=level,
noise_floor=noise_floor,
voice_enhance=voice_enhance,
)
def has_effect(self) -> bool:
"""判断是否有实际降噪效果。"""
return self.enabled
def get_effective_noise_floor(self) -> float:
"""获取实际生效的噪音阈值(dB)。"""
if self.level == NoiseReductionLevel.CUSTOM:
return self.noise_floor
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[NoiseReductionLevel.MEDIUM])
return float(params["nf"])
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
class NoiseReductionEngine: class NoiseReductionEngine:
"""音频降噪引擎 """音频降噪引擎 — 薄包装,实际逻辑在 domain.noise_reduction_config.
基于 FFmpeg afftdnAudio FFt Denoiser)滤镜实现: 基于 FFmpeg afftdnAudio FFt Denoiser)滤镜实现:
- 使用短时傅里叶变换分析音频频谱 - 使用短时傅里叶变换分析音频频谱
@@ -133,97 +35,40 @@ class NoiseReductionEngine:
self.config = config self.config = config
def build_filter(self, input_label: str, output_label: str) -> str: def build_filter(self, input_label: str, output_label: str) -> str:
"""构建音频降噪滤镜字符串 """构建音频降噪滤镜字符串.
Args: Args:
input_label: 输入标签,如 "[0:a]""[a0]" input_label: 输入标签,如 "[0:a]""[a0]"
output_label: 输出标签,如 "[nr0]" output_label: 输出标签,如 "[nr0]"
Returns: Returns:
FFmpeg 滤镜字符串,如 "[a0]afftdn=nf=-25:tn=-10:tr=50[nr0]" FFmpeg 滤镜字符串
Raises:
ValueError: 配置无效时抛出(调用方应捕获并降级)
""" """
if not self.config.has_effect(): return _build_afftdn_filter_base(self.config, input_label, output_label)
return f"{input_label}anull{output_label}"
# 获取参数
if self.config.level == NoiseReductionLevel.CUSTOM:
nf = self.config.noise_floor
tn = -10 # 默认频谱平滑度
tr = 50 # 默认时间分辨率
else:
params = _LEVEL_PARAMS.get(
self.config.level,
_LEVEL_PARAMS[NoiseReductionLevel.MEDIUM],
)
nf = float(params["nf"])
tn = float(params["tn"])
tr = float(params["tr"])
# 构建 afftdn 滤镜
# nf: noise floor (dB)
# tn: temporal noise floor smoothing (dB)
# tr: time resolution (ms)
filter_parts = [f"afftdn=nf={nf}:tn={tn}:tr={tr}"]
# 人声增强:通过 highpass + 轻微压缩实现
if self.config.voice_enhance:
# 1. 高通滤波,去除低频噪音
filter_parts.append("highpass=f=80")
# 2. 轻微压缩,提升人声清晰度
filter_parts.append("acompressor=threshold=-20:ratio=2:attack=5:release=50")
# 3. 响度归一化
filter_parts.append("loudnorm=I=-16:TP=-1.5:LRA=11")
filter_str = f"{input_label}{','.join(filter_parts)}{output_label}"
return filter_str
def build_filter_arnndn(self, input_label: str, output_label: str, model_file: str) -> str: def build_filter_arnndn(self, input_label: str, output_label: str, model_file: str) -> str:
"""使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件) """使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件).
注意:需要额外下载 RNNNoise 模型文件,默认使用 afftdn(无需额外依赖)。
Args: Args:
input_label: 输入标签 input_label: 输入标签
output_label: 输出标签 output_label: 输出标签
model_file: RNNNoise 模型文件路径.rnnn 格式) model_file: RNNNoise 模型文件路径
Returns: Returns:
FFmpeg 滤镜字符串 FFmpeg 滤镜字符串
""" """
if not self.config.has_effect(): return _build_arnndn_filter_base(self.config, input_label, output_label, model_file)
return f"{input_label}anull{output_label}"
return f"{input_label}arnndn=m={model_file}{output_label}"
def apply_noise_reduction_if_needed( def apply_noise_reduction_if_needed(config_data, input_label: str, output_label: str):
config_data: dict | None, """便捷函数:根据配置判断是否需要应用音频降噪.
input_label: str,
output_label: str,
) -> Optional[str]:
"""便捷函数:根据配置判断是否需要应用音频降噪。
Args: Args:
config_data: 降噪配置字典(从 plan.config.audio_noise_reduction 或 clip.config.noise_reduction 读取) config_data: 降噪配置字典
input_label: 输入标签 input_label: 输入标签
output_label: 输出标签 output_label: 输出标签
Returns: Returns:
滤镜字符串,不需要降噪时返回 None 滤镜字符串,不需要降噪时返回 None
""" """
if not config_data: return _apply_noise_reduction_if_needed_base(config_data, input_label, output_label)
return None
try:
config = NoiseReductionConfig.from_dict(config_data)
if not config.has_effect():
return None
engine = NoiseReductionEngine(config)
return engine.build_filter(input_label, output_label)
except Exception as e:
logger.warning("[noise-reduction] 应用降噪失败,跳过: %s", e)
return None
+41 -192
View File
@@ -14,174 +14,34 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__) # 向后兼容:POSITION_BOTTOM_CENTER 也从 pip_config 再导出
from packages.domain.pip_config import POSITION_BOTTOM_CENTER # noqa: E402, F401
from packages.domain.pip_config import (
# ── 位置常量 ──────────────────────────────────────────────────────────────────
# 9宫格位置枚举
POSITION_TOP_LEFT = "top_left"
POSITION_TOP_CENTER = "top_center"
POSITION_TOP_RIGHT = "top_right"
POSITION_CENTER_LEFT = "center_left"
POSITION_CENTER = "center"
POSITION_CENTER_RIGHT = "center_right"
POSITION_BOTTOM_LEFT = "bottom_left"
POSITION_BOTTOM_CENTER = "bottom_center"
POSITION_BOTTOM_RIGHT = "bottom_right"
_VALID_POSITIONS = {
POSITION_TOP_LEFT,
POSITION_TOP_CENTER,
POSITION_TOP_RIGHT,
POSITION_CENTER_LEFT,
POSITION_CENTER,
POSITION_CENTER_RIGHT,
POSITION_BOTTOM_LEFT,
POSITION_BOTTOM_CENTER,
POSITION_BOTTOM_RIGHT,
}
# 动画类型
ANIMATION_FADE = "fade" # 淡入淡出
ANIMATION_SLIDE_LEFT = "slide_left" # 从左滑入
ANIMATION_SLIDE_RIGHT = "slide_right" # 从右滑入
ANIMATION_SLIDE_TOP = "slide_top" # 从上滑入
ANIMATION_SLIDE_BOTTOM = "slide_bottom" # 从下滑入
_VALID_ANIMATIONS = {
ANIMATION_FADE, ANIMATION_FADE,
ANIMATION_SCALE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT, ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT, ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP, ANIMATION_SLIDE_TOP,
ANIMATION_SLIDE_BOTTOM, POSITION_BOTTOM_LEFT,
} POSITION_BOTTOM_RIGHT,
POSITION_CENTER,
POSITION_CENTER_LEFT,
POSITION_CENTER_RIGHT,
POSITION_TOP_CENTER,
POSITION_TOP_LEFT,
POSITION_TOP_RIGHT,
PiPConfig,
PiPLayerConfig,
)
from packages.domain.pip_config import ( # noqa: F401 — 向后兼容:保留模块级导出
calculate_pip_position as _calculate_pip_position_base,
)
from packages.domain.pip_config import parse_size_value as _parse_size_value_base
logger = logging.getLogger(__name__)
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class PiPLayerConfig:
"""单个画中画图层配置."""
# 素材来源
source: str = "" # 素材ID或视频URL
source_type: str = "asset_id" # "asset_id" | "url" | "local_path"
# 位置配置
position: str = POSITION_BOTTOM_RIGHT # 9宫格位置或 "custom"
x: int | str = 0 # 自定义x坐标(像素或百分比如 "30%"
y: int | str = 0 # 自定义y坐标
margin: int = 20 # 9宫格模式下的边距(像素)
# 大小配置
width: int | str = "25%" # 宽度(像素或百分比)
height: int | str = "" # 高度(空则按比例自适应)
# 样式
opacity: float = 1.0 # 透明度 0.0-1.0
corner_radius: int = 0 # 圆角半径(像素),0表示无圆角
border_width: int = 0 # 边框宽度
border_color: str = "white" # 边框颜色
# 时间控制
start_time: float = 0.0 # 开始显示时间(秒)
duration: float = 0.0 # 持续时长(秒),0表示全程显示
# 动画
animation_in: str = "" # 入场动画类型
animation_out: str = "" # 出场动画类型
animation_duration: float = 0.5 # 动画时长(秒)
# 层级
z_index: int = 1 # 图层顺序,数字越大越在上层
def validate(self) -> tuple[bool, str]:
"""校验配置合法性,返回 (是否合法, 错误信息)."""
if not self.source:
return False, "source不能为空"
if self.position != "custom" and self.position not in _VALID_POSITIONS:
return False, f"无效的position: {self.position}"
if self.opacity < 0 or self.opacity > 1:
return False, "opacity必须在0-1之间"
if self.corner_radius < 0:
return False, "corner_radius不能为负数"
if self.start_time < 0:
return False, "start_time不能为负数"
if self.duration < 0:
return False, "duration不能为负数"
if self.animation_in and self.animation_in not in _VALID_ANIMATIONS:
return False, f"无效的入场动画: {self.animation_in}"
if self.animation_out and self.animation_out not in _VALID_ANIMATIONS:
return False, f"无效的出场动画: {self.animation_out}"
if self.animation_duration < 0:
return False, "animation_duration不能为负数"
return True, ""
@dataclass
class PiPConfig:
"""画中画整体配置."""
enabled: bool = False
layers: list[PiPLayerConfig] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
layers_data = data.get("layers", [])
layers = []
for layer_data in layers_data:
try:
layer = PiPLayerConfig(
source=layer_data.get("source", ""),
source_type=layer_data.get("source_type", "asset_id"),
position=layer_data.get("position", POSITION_BOTTOM_RIGHT),
x=layer_data.get("x", 0),
y=layer_data.get("y", 0),
margin=int(layer_data.get("margin", 20)),
width=layer_data.get("width", "25%"),
height=layer_data.get("height", ""),
opacity=float(layer_data.get("opacity", 1.0)),
corner_radius=int(layer_data.get("corner_radius", 0)),
border_width=int(layer_data.get("border_width", 0)),
border_color=layer_data.get("border_color", "white"),
start_time=float(layer_data.get("start_time", 0.0)),
duration=float(layer_data.get("duration", 0.0)),
animation_in=layer_data.get("animation_in", ""),
animation_out=layer_data.get("animation_out", ""),
animation_duration=float(layer_data.get("animation_duration", 0.5)),
z_index=int(layer_data.get("z_index", 1)),
)
valid, err = layer.validate()
if valid:
layers.append(layer)
else:
logger.warning("PiP图层配置无效,跳过: %s", err)
except (ValueError, TypeError) as e:
logger.warning("PiP图层解析失败,跳过: %s", e)
# 按 z_index 排序
layers.sort(key=lambda layer: layer.z_index)
return cls(enabled=bool(layers), layers=layers)
# ── PiP 引擎 ────────────────────────────────────────────────────────────────── # ── PiP 引擎 ──────────────────────────────────────────────────────────────────
@@ -201,16 +61,12 @@ class PiPEngine:
self.output_fps = output_fps self.output_fps = output_fps
def _parse_size(self, value: int | str, base: int) -> int: def _parse_size(self, value: int | str, base: int) -> int:
"""解析尺寸值(像素或百分比).""" """解析尺寸值(像素或百分比).
if isinstance(value, int):
return max(1, value) 委托给 packages.domain.pip_config.parse_size_value 纯逻辑函数,
if isinstance(value, str) and value.endswith("%"): 薄包装保留在类内以维持向后兼容。
pct = float(value.rstrip("%")) / 100.0 """
return max(1, int(base * pct)) return _parse_size_value_base(value, base)
try:
return max(1, int(value))
except (ValueError, TypeError):
return int(base * 0.25) # 默认25%
def _parse_position( def _parse_position(
self, self,
@@ -218,28 +74,21 @@ class PiPEngine:
pip_width: int, pip_width: int,
pip_height: int, pip_height: int,
) -> tuple[int, int]: ) -> tuple[int, int]:
"""计算画中画的实际位置 (x, y).""" """计算画中画的实际位置 (x, y).
W = self.output_width
H = self.output_height
m = layer.margin
if layer.position == "custom": 委托给 packages.domain.pip_config.calculate_pip_position 纯逻辑函数,
x = self._parse_size(layer.x, W) 薄包装保留在类内以维持向后兼容。
y = self._parse_size(layer.y, H) """
return (x, y) return _calculate_pip_position_base(
position=layer.position,
pos_map = { output_width=self.output_width,
POSITION_TOP_LEFT: (m, m), output_height=self.output_height,
POSITION_TOP_CENTER: ((W - pip_width) // 2, m), pip_width=pip_width,
POSITION_TOP_RIGHT: (W - pip_width - m, m), pip_height=pip_height,
POSITION_CENTER_LEFT: (m, (H - pip_height) // 2), margin=layer.margin,
POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2), custom_x=layer.x,
POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2), custom_y=layer.y,
POSITION_BOTTOM_LEFT: (m, H - pip_height - m), )
POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m),
POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m),
}
return pos_map.get(layer.position, pos_map[POSITION_BOTTOM_RIGHT])
def _build_pip_pre_filter( def _build_pip_pre_filter(
self, self,
+37 -203
View File
@@ -1,8 +1,8 @@
"""ASS 字幕生成模块 — 从 unified_render_service.py 拆分. """ASS 字幕生成模块 — 薄包装,实际逻辑在 packages/domain/ass_subtitle_builder.py.
职责: 职责:
- 将 title / subtitle 配置转换为 ASS 字幕文件 - 将 title / subtitle 配置转换为 ASS 字幕文件
- 提供样式计算(颜色、对齐、描边/阴影) - 文件IO 在此模块,纯逻辑已抽离到 domain
- 供 UnifiedRenderService._maybe_generate_ass 调用 - 供 UnifiedRenderService._maybe_generate_ass 调用
""" """
@@ -12,107 +12,40 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
build_ass_content,
)
from packages.domain.ass_subtitle_builder import build_ass_style as _build_ass_style_base # noqa: F401 — 向后兼容
from packages.domain.ass_subtitle_builder import escape_ass_text as _escape_ass_text_base
from packages.domain.ass_subtitle_builder import format_ass_time as _format_ass_time_base
from packages.domain.ass_subtitle_builder import hex_to_ass_color as _hex_to_ass_color_base
from packages.domain.ass_subtitle_builder import position_to_ass_alignment as _position_to_ass_alignment_base
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 常量 ────────────────────────────────────────────────────────────────────── # 向后兼容:模块级函数保留为薄包装
# Title/Subtitle 默认边距(像素)
TITLE_MARGIN_TOP = 60
TITLE_MARGIN_BOTTOM = 60
TITLE_MARGIN_SIDE = 40
# ── ASS 字幕工具 ─────────────────────────────────────────────────────────────
def _hex_to_ass_color(hex_color: str) -> str: def _hex_to_ass_color(hex_color: str) -> str:
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式。""" return _hex_to_ass_color_base(hex_color)
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H000000"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H{b.upper()}{g.upper()}{r.upper()}"
def _position_to_ass_alignment(position: str) -> int: def _position_to_ass_alignment(position: str) -> int:
"""将文字位置映射为 ASS \\an 对齐编号。 return _position_to_ass_alignment_base(position)
ASS 对齐编号(数字小键盘布局):
7 8 9
4 5 6
1 2 3
"""
mapping = {
"top": 8, # 顶部居中
"center": 5, # 居中
"bottom": 2, # 底部居中
}
return mapping.get(position, 8)
def _build_ass_style( def _build_ass_style(*args, **kwargs) -> str:
style_name: str, return _build_ass_style_base(*args, **kwargs)
*,
font_name: str = "思源黑体",
font_size: int = 48,
primary_color: str = "&H00FFFFFF",
outline_color: str = "&H00000000",
outline_width: float = 1.0,
shadow_blur: float = 0.0,
shadow_offset: tuple[int, int] = (0, 0),
bold: bool = False,
italic: bool = False,
alignment: int = 8,
margin_v: int = 60,
margin_l: int = 40,
margin_r: int = 40,
) -> str:
"""构建 ASS Style 行。
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
"""
bold_val = -1 if bold else 0
italic_val = -1 if italic else 0
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow
back_color = primary_color # 阴影颜色默认同文字色(带透明度由阴影模糊控制)
# Shadow 值:ASS 中 Shadow 字段是阴影偏移距离(像素),
# 我们用 shadow_offset[1] 作为纵向偏移,模糊由 BorderStyle=3 实现
# 简化:BorderStyle=1outline + drop shadow),Shadow 字段表示阴影深度
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
return (
f"Style: {style_name},{font_name},{font_size},{primary_color},"
f"&H000000FF,{outline_color},{back_color},"
f"{bold_val},{italic_val},0,0,100,100,0,0,"
f"1,{outline_width},{shadow_depth},{alignment},"
f"{margin_l},{margin_r},{margin_v},1"
)
def _escape_ass_text(text: str) -> str: def _escape_ass_text(text: str) -> str:
r"""转义 ASS 文本中的特殊字符。 return _escape_ass_text_base(text)
ASS 中换行用 \N(硬换行)或 \n(软换行),
大括号 {} 用于覆盖样式,需要转义。
"""
# 将实际换行转为 ASS 硬换行
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
# 转义大括号(ASS 用它做样式覆盖标签)
text = text.replace("{", "(").replace("}", ")")
return text
def _format_ass_time(seconds: float) -> str: def _format_ass_time(seconds: float) -> str:
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc。""" return _format_ass_time_base(seconds)
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def generate_ass_subtitles( def generate_ass_subtitles(
@@ -126,130 +59,31 @@ def generate_ass_subtitles(
subtitle_text: str = "", subtitle_text: str = "",
subtitle_config: dict[str, Any] | None = None, subtitle_config: dict[str, Any] | None = None,
) -> Path: ) -> Path:
"""生成 ASS 字幕文件 """生成 ASS 字幕文件.
支持 Title(标题)和 Subtitle(字幕)两种字幕类型,
各自可独立配置样式、位置和内容。
Args: Args:
output_path: 输出 ASS 文件路径 output_path: 输出 ASS 文件路径
video_width: 视频宽度(用于 ASS PlayResX video_width: 视频宽度
video_height: 视频高度(用于 ASS PlayResY video_height: 视频高度
video_duration: 视频总时长(秒),字幕显示整个时长 video_duration: 视频总时长(秒)
title_text: 标题文本 title_text: 标题文本
title_config: 标题样式配置TitleConfig dict title_config: 标题样式配置
subtitle_text: 字幕文本 subtitle_text: 字幕文本
subtitle_config: 字幕样式配置SubtitleConfig dict subtitle_config: 字幕样式配置
Returns: Returns:
生成的 ASS 文件路径 生成的 ASS 文件路径
""" """
title_config = title_config or {} content = build_ass_content(
subtitle_config = subtitle_config or {} video_width=video_width,
video_height=video_height,
title_enabled = title_config.get("enabled", True) and bool(title_text.strip()) video_duration=video_duration,
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip()) title_text=title_text,
title_config=title_config,
if not title_enabled and not subtitle_enabled: subtitle_text=subtitle_text,
# 没有字幕,生成空文件(仍返回路径,调用方自行判断是否使用) subtitle_config=subtitle_config,
output_path.write_text("", encoding="utf-8") )
return output_path
styles: list[str] = []
events: list[str] = []
# ── Title 样式与事件 ──────────────────────────────────────────────────
if title_enabled:
title_color = _hex_to_ass_color(title_config.get("color", "#ffffff"))
title_stroke = title_config.get("stroke", {}) or {}
title_shadow = title_config.get("shadow", {}) or {}
stroke_color = _hex_to_ass_color(title_stroke.get("color", "#000000"))
stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
shadow_offset = (
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
)
title_alignment = _position_to_ass_alignment(title_config.get("position", "top"))
styles.append(
_build_ass_style(
"TitleStyle",
font_name=title_config.get("font", "思源黑体"),
font_size=int(title_config.get("size", 48)),
primary_color=title_color,
outline_color=stroke_color,
outline_width=stroke_width,
shadow_blur=shadow_blur,
shadow_offset=shadow_offset,
bold=bool(title_config.get("bold", True)),
italic=bool(title_config.get("italic", False)),
alignment=title_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
)
# 转义 ASS 特殊字符
safe_title_text = _escape_ass_text(title_text)
events.append(
"Dialogue: 0,0:00:00.00," f"{_format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
)
# ── Subtitle 样式与事件 ───────────────────────────────────────────────
if subtitle_enabled:
sub_color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
sub_alignment = _position_to_ass_alignment(subtitle_config.get("position", "bottom"))
styles.append(
_build_ass_style(
"SubtitleStyle",
font_name=subtitle_config.get("font", "思源黑体"),
font_size=int(subtitle_config.get("size", 24)),
primary_color=sub_color,
outline_color="&H00000000",
outline_width=1.0,
shadow_blur=0.0,
shadow_offset=(0, 0),
bold=False,
italic=False,
alignment=sub_alignment,
margin_v=TITLE_MARGIN_BOTTOM,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
)
safe_subtitle_text = _escape_ass_text(subtitle_text)
events.append(
"Dialogue: 0,0:00:00.00,"
f"{_format_ass_time(video_duration)},"
"SubtitleStyle,,0,0,0,,"
f"{safe_subtitle_text}"
)
# ── 组装 ASS 文件 ─────────────────────────────────────────────────────
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
PlayResY: {video_height}
ScaledBorderAndShadow: yes
WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
{chr(10).join(styles)}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
"""
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(ass_content, encoding="utf-8") output_path.write_text(content, encoding="utf-8")
return output_path return output_path
+30 -151
View File
@@ -11,121 +11,25 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from packages.domain.sticker_config import (
POSITION_PRESETS,
STICKER_CATEGORIES,
ImageStickerConfig,
StickerOverlayResult,
TextStickerConfig,
)
from packages.domain.sticker_config import get_sticker_categories as _get_sticker_categories_base # noqa: F401 向后兼容导出
from packages.domain.sticker_config import parse_stickers_from_config as _parse_stickers_base
from packages.domain.sticker_config import (
resolve_sticker_position,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 预设贴纸分类 ──────────────────────────────────────────────────────────────
# 预设贴纸分类(仅用于前端展示,后端不依赖具体素材)
STICKER_CATEGORIES = [
("emoji", "表情包"),
("text", "文字花字"),
("decoration", "装饰"),
("arrow", "箭头指示"),
("frame", "边框"),
]
# 9宫格位置映射
POSITION_PRESETS = {
"top_left": (0.05, 0.05),
"top_center": (0.5, 0.05),
"top_right": (0.95, 0.05),
"center_left": (0.05, 0.5),
"center": (0.5, 0.5),
"center_right": (0.95, 0.5),
"bottom_left": (0.05, 0.95),
"bottom_center": (0.5, 0.95),
"bottom_right": (0.95, 0.95),
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ImageStickerConfig:
"""图片贴纸配置."""
enabled: bool = False
type: str = "image" # image / text
# 位置
position: str = "top_right" # 9宫格预设
x: float | None = None # 自定义x(像素或百分比)
y: float | None = None # 自定义y
x_unit: str = "percent" # pixel / percent
y_unit: str = "percent"
# 大小
scale: float = 1.0 # 缩放比例(相对于原始大小)
width: int | None = None # 指定宽度(像素)
height: int | None = None # 指定高度(像素)
# 透明度
opacity: float = 1.0 # 0.0~1.0
# 时间范围
start_time: float = 0.0
duration: float = 0.0 # 0 表示持续到结束
# 动画
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长
# 层级
z_index: int = 10
# 素材
image_url: str = "" # 图片URL或本地路径
preset_id: str = "" # 预设贴纸ID
@dataclass
class TextStickerConfig:
"""文字贴纸配置."""
enabled: bool = False
type: str = "text"
text: str = ""
# 字体
font_size: int = 36
font_color: str = "#FFFFFF"
font_family: str = "sans"
# 描边
stroke_color: str = "#000000"
stroke_width: int = 2
# 阴影
shadow_color: str = "#000000"
shadow_x: int = 2
shadow_y: int = 2
shadow_alpha: float = 0.5
# 位置
position: str = "center"
x: float | None = None
y: float | None = None
x_unit: str = "percent"
y_unit: str = "percent"
# 时间范围
start_time: float = 0.0
duration: float = 0.0
# 动画
fade_in: float = 0.0
fade_out: float = 0.0
# 层级
z_index: int = 10
# 背景框
bg_color: str = "" # 空表示无背景
bg_padding: int = 8
bg_alpha: float = 0.8
bg_corner_radius: int = 8
@dataclass
class StickerOverlayResult:
"""贴纸叠加结果."""
filter_str: str # 滤镜字符串
output_label: str # 输出标签
extra_inputs: list[str] = field(default_factory=list) # 额外的输入文件路径
# ── 贴纸引擎 ────────────────────────────────────────────────────────────────── # ── 贴纸引擎 ──────────────────────────────────────────────────────────────────
@@ -144,38 +48,18 @@ class StickerEngine:
sticker_w: int = 0, sticker_w: int = 0,
sticker_h: int = 0, sticker_h: int = 0,
) -> tuple[float, float]: ) -> tuple[float, float]:
"""解析贴纸位置(像素坐标). """解析贴纸位置(像素坐标)(转发到 sticker_config 模块)."""
return resolve_sticker_position(
优先级:自定义坐标 > 9宫格预设 config.position,
""" config.x,
# 先取预设的基准位置 config.y,
if config.position in POSITION_PRESETS: config.x_unit,
px, py = POSITION_PRESETS[config.position] config.y_unit,
else: canvas_w,
px, py = 0.5, 0.5 # 默认居中 canvas_h,
sticker_w,
# 自定义坐标覆盖 sticker_h,
if config.x is not None: )
if config.x_unit == "percent":
px = config.x / 100.0
else:
px = config.x / canvas_w if canvas_w > 0 else 0.5
if config.y is not None:
if config.y_unit == "percent":
py = config.y / 100.0
else:
py = config.y / canvas_h if canvas_h > 0 else 0.5
# 转换为像素坐标(考虑贴纸尺寸,使位置为贴纸中心点)
x = px * canvas_w - sticker_w / 2
y = py * canvas_h - sticker_h / 2
# 钳制在画布内
x = max(0, min(x, canvas_w - sticker_w))
y = max(0, min(y, canvas_h - sticker_h))
return x, y
@staticmethod @staticmethod
def _build_overlay_filter( def _build_overlay_filter(
@@ -594,19 +478,14 @@ class StickerEngine:
return None return None
# ── 便捷函数 ────────────────────────────────────────────────────────────────── # ── 便捷函数(薄包装,转发到 sticker_config 模块) ────────────────────────────
def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]: def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]:
"""从 plan.config.stickers 解析贴纸列表.""" """从 plan.config.stickers 解析贴纸列表(薄包装)."""
if not config: return _parse_stickers_base(config)
return []
stickers = config.get("stickers", [])
if not isinstance(stickers, list):
return []
return stickers
def get_sticker_categories() -> list[tuple[str, str]]: def get_sticker_categories() -> list[tuple[str, str]]:
"""获取贴纸分类列表.""" """获取贴纸分类列表(薄包装)."""
return list(STICKER_CATEGORIES) return _get_sticker_categories_base()
@@ -24,264 +24,34 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
from packages.domain.subtitle_style import (
ALLOWED_SUBTITLE_EXTENSIONS,
DEFAULT_COLOR,
DEFAULT_FONT,
DEFAULT_FONT_SIZE,
DEFAULT_MAX_CHARS_PER_LINE,
DEFAULT_POSITION,
DEFAULT_STROKE_COLOR,
DEFAULT_STROKE_WIDTH,
POSITION_ALIASES,
POSITION_ALIGNMENT,
SubtitleSegment,
SubtitleStyle,
)
from packages.domain.subtitle_style import escape_ass_text as _escape_ass_text # noqa: F401 向后兼容导出
from packages.domain.subtitle_style import format_ass_time as _format_ass_time
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha
from packages.domain.subtitle_style import wrap_text as _wrap_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"}
# 9宫格位置映射(ASS alignment 编号)
POSITION_ALIGNMENT = {
"top_left": 7,
"top_center": 8,
"top_right": 9,
"middle_left": 4,
"center": 5,
"middle_right": 6,
"bottom_left": 1,
"bottom_center": 2,
"bottom_right": 3,
}
# 位置简称兼容
POSITION_ALIASES = {
"top": "top_center",
"bottom": "bottom_center",
"middle": "center",
"left": "middle_left",
"right": "middle_right",
}
DEFAULT_FONT = "思源黑体"
DEFAULT_FONT_SIZE = 24
DEFAULT_COLOR = "#FFFFFF"
DEFAULT_STROKE_COLOR = "#000000"
DEFAULT_STROKE_WIDTH = 1.5
DEFAULT_POSITION = "bottom_center"
DEFAULT_MAX_CHARS_PER_LINE = 20
# ── 字幕样式配置 ────────────────────────────────────────────────────────────
@dataclass
class SubtitleStyle:
"""字幕样式配置."""
font_name: str = DEFAULT_FONT
font_size: int = DEFAULT_FONT_SIZE
font_color: str = DEFAULT_COLOR
bold: bool = False
italic: bool = False
# 描边
stroke_enabled: bool = True
stroke_color: str = DEFAULT_STROKE_COLOR
stroke_width: float = DEFAULT_STROKE_WIDTH
# 阴影
shadow_enabled: bool = False
shadow_color: str = "#000000"
shadow_offset_x: int = 2
shadow_offset_y: int = 2
shadow_blur: float = 0.0
# 背景框
background_enabled: bool = False
background_color: str = "#000000"
background_opacity: float = 0.5 # 0.0 ~ 1.0
background_padding: int = 8
background_radius: int = 4
# 位置
position: str = DEFAULT_POSITION # 9宫格位置名
margin_v: int = 60 # 垂直边距
margin_l: int = 40 # 左边距
margin_r: int = 40 # 右边距
# 多行
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE
line_spacing: int = 0 # 行间距
# 动画
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
animation_type: str = "none" # none/fade/slide/typewriter
@classmethod
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
"""从字典创建样式配置,带安全类型转换."""
if not config or not isinstance(config, dict):
return cls()
def safe_str(key: str, default: str) -> str:
val = config.get(key, default)
return str(val) if val is not None else default
def safe_int(key: str, default: int) -> int:
try:
return int(config.get(key, default))
except (TypeError, ValueError):
return default
def safe_float(key: str, default: float) -> float:
try:
return float(config.get(key, default))
except (TypeError, ValueError):
return default
def safe_bool(key: str, default: bool) -> bool:
return bool(config.get(key, default))
position = safe_str("position", DEFAULT_POSITION)
position = POSITION_ALIASES.get(position, position)
if position not in POSITION_ALIGNMENT:
position = DEFAULT_POSITION
return cls(
font_name=safe_str("font", DEFAULT_FONT),
font_size=safe_int("size", DEFAULT_FONT_SIZE),
font_color=safe_str("color", DEFAULT_COLOR),
bold=safe_bool("bold", False),
italic=safe_bool("italic", False),
stroke_enabled=safe_bool("stroke_enabled", True),
stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR),
stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH),
shadow_enabled=safe_bool("shadow_enabled", False),
shadow_color=safe_str("shadow_color", "#000000"),
shadow_offset_x=safe_int("shadow_offset_x", 2),
shadow_offset_y=safe_int("shadow_offset_y", 2),
shadow_blur=safe_float("shadow_blur", 0.0),
background_enabled=safe_bool("background_enabled", False),
background_color=safe_str("background_color", "#000000"),
background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))),
background_padding=safe_int("background_padding", 8),
background_radius=safe_int("background_radius", 4),
position=position,
margin_v=safe_int("margin_v", 60),
margin_l=safe_int("margin_l", 40),
margin_r=safe_int("margin_r", 40),
max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE),
line_spacing=safe_int("line_spacing", 0),
fade_in=max(0.0, safe_float("fade_in", 0.0)),
fade_out=max(0.0, safe_float("fade_out", 0.0)),
animation_type=safe_str("animation_type", "none"),
)
@property
def alignment(self) -> int:
"""获取 ASS alignment 编号."""
return POSITION_ALIGNMENT.get(self.position, 2)
@property
def ass_font_color(self) -> str:
"""ASS 格式颜色 &HAABBGGRR."""
return _hex_to_ass_color(self.font_color)
@property
def ass_stroke_color(self) -> str:
return _hex_to_ass_color(self.stroke_color)
@property
def ass_shadow_color(self) -> str:
return _hex_to_ass_color(self.shadow_color)
@property
def ass_background_color(self) -> str:
"""背景框颜色(ASS BackColour),带透明度."""
alpha_hex = _opacity_to_ass_alpha(self.background_opacity)
color_bgr = _hex_to_ass_bgr(self.background_color)
return f"&H{alpha_hex}{color_bgr}"
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _hex_to_ass_color(hex_color: str) -> str:
"""HEX → ASS 颜色 &HAABBGGRR(默认不透明)."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H00FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H00{b.upper()}{g.upper()}{r.upper()}"
def _hex_to_ass_bgr(hex_color: str) -> str:
"""HEX → ASS BGR 部分(不含 alpha."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"{b.upper()}{g.upper()}{r.upper()}"
def _opacity_to_ass_alpha(opacity: float) -> str:
"""不透明度 → ASS alpha(00=不透明,FF=完全透明)."""
alpha = 255 - int(opacity * 255)
return f"{alpha:02X}"
def _escape_ass_text(text: str) -> str:
"""转义 ASS 文本特殊字符."""
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
text = text.replace("{", "(").replace("}", ")")
return text
def _format_ass_time(seconds: float) -> str:
"""秒 → ASS 时间格式 H:MM:SS.cc."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def _wrap_text(text: str, max_chars: int) -> list[str]:
"""按字数换行,优先标点断开."""
if len(text) <= max_chars:
return [text]
lines: list[str] = []
remaining = text
while len(remaining) > max_chars:
break_point = max_chars
punctuations = ",。!?、;:,.;:!?"
for i in range(max_chars, max_chars // 2, -1):
if i < len(remaining) and remaining[i] in punctuations:
break_point = i + 1
break
lines.append(remaining[:break_point])
remaining = remaining[break_point:]
if remaining:
lines.append(remaining)
return lines
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
@dataclass
class SubtitleSegment:
"""单个字幕片段."""
start: float # 开始时间(秒)
end: float # 结束时间(秒)
text: str # 字幕文本
style_name: str = "Default" # 使用的样式名
# ── 字幕渲染引擎 ────────────────────────────────────────────────────────────── # ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
@@ -12,229 +12,21 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from dataclasses import dataclass
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from enum import Enum
class StrEnum(str, Enum):
pass
from video_processing.ffmpeg_utils import build_xfade_filter_chain from video_processing.ffmpeg_utils import build_xfade_filter_chain
from packages.domain.transition_config import ( # noqa: F401 — 向后兼容
CUT_TRANSITION,
DEFAULT_TRANSITION_DURATION,
MAX_TRANSITION_DURATION,
MIN_TRANSITION_DURATION,
TransitionConfig,
TransitionType,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# 转场时长范围(秒)
MIN_TRANSITION_DURATION = 0.3
MAX_TRANSITION_DURATION = 2.0
DEFAULT_TRANSITION_DURATION = 0.5
# 硬切(无转场)
CUT_TRANSITION = "cut"
# ── 转场类型枚举 ──────────────────────────────────────────────────────────────
class TransitionType(StrEnum):
"""支持的转场效果类型.
每种类型对应 FFmpeg xfade filter 的一个 transition 值。
新增转场只需在此添加一项,并在 _FFMPEG_XFADE_MAP 中映射。
"""
# 硬切(无转场效果,直接拼接)
CUT = "cut"
# 淡入淡出(最常用,默认 fallback)
FADE = "fade"
# 溶解(交叉溶解)
DISSOLVE = "dissolve"
# 滑入系列
SLIDE_LEFT = "slideleft"
SLIDE_RIGHT = "slideright"
SLIDE_UP = "slideup"
SLIDE_DOWN = "slidedown"
# 缩放
ZOOM = "zoom"
# 擦除系列
WIPE_LEFT = "wipeleft"
WIPE_RIGHT = "wiperight"
WIPE_UP = "wipeup"
WIPE_DOWN = "wipedown"
# 圆形扩散
CIRCLE_CROP = "circlecrop"
# 矩形覆盖
RECT_CROP = "rectcrop"
@classmethod
def all_supported(cls) -> list[str]:
"""返回所有支持的转场类型名称列表."""
return [t.value for t in cls if t != cls.CUT]
@classmethod
def is_supported(cls, name: str) -> bool:
"""检查转场类型是否支持(不区分大小写和下划线)."""
normalized = _normalize_transition_name(name)
return normalized in _NAME_TO_ENUM_MAP
# ── 名称 → 枚举 映射(支持多种别名)──────────────────────────────────────────
def _normalize_transition_name(name: str) -> str:
"""标准化转场名称:小写 + 去下划线."""
return name.lower().replace("_", "").replace("-", "")
# 构建别名映射
_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {}
for _t in TransitionType:
_NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t
# 额外的别名
_ALIASES: dict[str, TransitionType] = {
"dissolve": TransitionType.DISSOLVE,
"crossfade": TransitionType.DISSOLVE,
"crossdissolve": TransitionType.DISSOLVE,
"fadein": TransitionType.FADE,
"fadeout": TransitionType.FADE,
"fadeblack": TransitionType.FADE,
"slide": TransitionType.SLIDE_LEFT, # 默认向左滑
"wipe": TransitionType.WIPE_LEFT, # 默认向左擦
"zoomin": TransitionType.ZOOM,
"zoomout": TransitionType.ZOOM,
"circle": TransitionType.CIRCLE_CROP,
"rect": TransitionType.RECT_CROP,
}
for _alias, _type in _ALIASES.items():
_key = _normalize_transition_name(_alias)
if _key not in _NAME_TO_ENUM_MAP:
_NAME_TO_ENUM_MAP[_key] = _type
# ── TransitionType → FFmpeg xfade transition 名称映射 ─────────────────────────
_FFMPEG_XFADE_MAP: dict[TransitionType, str] = {
TransitionType.FADE: "fade",
TransitionType.DISSOLVE: "dissolve",
TransitionType.SLIDE_LEFT: "slideleft",
TransitionType.SLIDE_RIGHT: "slideright",
TransitionType.SLIDE_UP: "slideup",
TransitionType.SLIDE_DOWN: "slidedown",
TransitionType.ZOOM: "zoomin",
TransitionType.WIPE_LEFT: "wipeleft",
TransitionType.WIPE_RIGHT: "wiperight",
TransitionType.WIPE_UP: "wipeup",
TransitionType.WIPE_DOWN: "wipedown",
TransitionType.CIRCLE_CROP: "circlecrop",
TransitionType.RECT_CROP: "rectcrop",
}
# ── 转场配置 ──────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class TransitionConfig:
"""转场效果配置.
Attributes:
effect: 转场效果名称(见 TransitionType
duration: 转场时长(秒),范围 0.3~2.0,默认 0.5
"""
effect: str = CUT_TRANSITION
duration: float = DEFAULT_TRANSITION_DURATION
@classmethod
def parse(cls, effect: str | None = None, duration: float | None = None) -> "TransitionConfig":
"""解析并验证转场配置,自动处理边界和降级.
Args:
effect: 转场效果名称(None 或空则使用默认 cut)
duration: 转场时长(None 则使用默认值)
Returns:
验证后的 TransitionConfig
"""
# 处理 effect
final_effect = CUT_TRANSITION
if effect and effect.strip():
effect_clean = effect.strip()
if TransitionType.is_supported(effect_clean):
final_effect = _resolve_transition_enum(effect_clean).value
elif effect_clean.lower() == CUT_TRANSITION:
final_effect = CUT_TRANSITION
else:
# 降级:不支持的转场 → 硬切,不阻断渲染
logger.warning(
"不支持的转场效果 '%s',已降级为硬切(cut",
effect_clean,
)
final_effect = CUT_TRANSITION
# 处理 duration:边界钳制
final_duration = DEFAULT_TRANSITION_DURATION
if duration is not None:
try:
d = float(duration)
if d < MIN_TRANSITION_DURATION:
logger.warning(
"转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值",
d,
MIN_TRANSITION_DURATION,
)
final_duration = MIN_TRANSITION_DURATION
elif d > MAX_TRANSITION_DURATION:
logger.warning(
"转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值",
d,
MAX_TRANSITION_DURATION,
)
final_duration = MAX_TRANSITION_DURATION
else:
final_duration = d
except (TypeError, ValueError):
logger.warning("无效的转场时长 '%s',使用默认值 %.1fs", duration, DEFAULT_TRANSITION_DURATION)
final_duration = DEFAULT_TRANSITION_DURATION
return cls(effect=final_effect, duration=final_duration)
@property
def is_cut(self) -> bool:
"""是否为硬切(无转场效果)."""
return self.effect == CUT_TRANSITION
@property
def ffmpeg_transition(self) -> str:
"""获取对应的 FFmpeg xfade transition 名称."""
if self.is_cut:
return ""
enum_type = _resolve_transition_enum(self.effect)
return _FFMPEG_XFADE_MAP.get(enum_type, "fade")
def _resolve_transition_enum(name: str) -> TransitionType:
"""将名称解析为 TransitionType 枚举,必须先通过 is_supported 校验."""
normalized = _normalize_transition_name(name)
return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE)
# ── 转场引擎 ────────────────────────────────────────────────────────────────── # ── 转场引擎 ──────────────────────────────────────────────────────────────────
+28 -298
View File
@@ -5,169 +5,37 @@
- 边界自动钳制(超出素材时长自动修正,不阻断渲染) - 边界自动钳制(超出素材时长自动修正,不阻断渲染)
- 多段裁剪(一个素材裁剪出多段) - 多段裁剪(一个素材裁剪出多段)
- 音画同步(视频 + 音频同步裁剪) - 音画同步(视频 + 音频同步裁剪)
注:核心领域模型已抽离到 packages/domain/trim_config.py
本模块保留薄包装层,确保向后兼容。
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from typing import Any from typing import Any
from packages.domain.trim_config import (
MIN_TRIM_DURATION,
TrimConfig,
TrimSegment,
)
from packages.domain.trim_config import build_audio_trim_filter as _build_audio_trim_filter # noqa: F401 — 向后兼容
from packages.domain.trim_config import build_video_trim_filter as _build_video_trim_filter
from packages.domain.trim_config import (
extract_trim_from_clip_config,
)
from packages.domain.trim_config import parse_segments_from_config as _parse_segments_from_config
from packages.domain.trim_config import resolve_segments as _resolve_segments
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 最小裁剪时长(秒),低于此值视为无效
MIN_TRIM_DURATION = 0.1
@dataclass
class TrimConfig:
"""裁剪配置.
三选二规则:start_time / end_time / duration 中必须至少给出两个,
第三个会被自动推导。如果三个都给了,以 start_time + duration 为准。
边界保护:
- start_time < 0 → 钳制到 0
- end_time > 素材时长 → 钳制到素材时长
- 计算出的 duration < 最小阈值 → 标记为无效
"""
start_time: float = 0.0 # 入点(素材内时间,秒)
end_time: float = 0.0 # 出点(素材内时间,秒),0 表示未指定
duration: float = 0.0 # 裁剪时长(秒),0 表示未指定
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> TrimConfig | None:
"""从字典构造,无有效裁剪参数时返回 None(不裁剪)."""
if not data:
return None
start = float(data.get("start_time", 0) or 0)
end = float(data.get("end_time", 0) or 0)
dur = float(data.get("duration", 0) or 0)
# 三个参数都没有 → 不裁剪
if start <= 0 and end <= 0 and dur <= 0:
return None
# 至少有两个参数(或一个合理的 start/duration
# 兼容:只传了 start_time → 从 start 开始取到末尾
# 兼容:只传了 duration → 从 0 开始取 duration
if start > 0 and end <= 0 and dur <= 0:
# 只有 start,取到末尾 → 这是"从某点开始"的语义,算有效
pass
elif dur > 0 and start <= 0 and end <= 0:
# 只有 duration → 从开头取 duration,算有效
pass
elif start <= 0 and end <= 0 and dur <= 0:
return None
return cls(start_time=start, end_time=end, duration=dur)
def validate_and_resolve(self, asset_duration: float) -> TrimConfig:
"""根据素材实际时长,解析并钳制裁剪参数.
返回一个新的 TrimConfig,其中 start_time / end_time / duration 都已确定。
如果裁剪无效(时长为0或负数),仍返回但调用方应检查 is_valid。
"""
start = self.start_time
end = self.end_time
dur = self.duration
# 边界:start 不能为负
if start < 0:
start = 0.0
# 边界:asset_duration 为 0 时保守处理(不裁剪,取全部)
if asset_duration <= 0:
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
# 三选二推导
# 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的
# 情况1start + end 都有显式值
if start > 0 and end > 0:
if end <= start:
# 出点 <= 入点,无效 → 返回 start 处一个极短片段(调用方会判无效)
return TrimConfig(start_time=start, end_time=start, duration=0.0)
dur = end - start
# 情况2end + duration 都有显式值
elif end > 0 and dur > 0:
start = end - dur
if start < 0:
start = 0.0
dur = end # 重新计算
# 情况3start + duration 都有值(start 可以是 0
elif dur > 0:
end = start + dur
# 情况4:只有 start → 取到素材末尾
elif start > 0 and end <= 0 and dur <= 0:
end = asset_duration
dur = end - start
# 情况5:只有 end → 从开头取到 end
elif end > 0 and start <= 0 and dur <= 0:
start = 0.0
dur = end
else:
# 都没有 → 不裁剪
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
# 边界钳制:end 不能超过素材时长
if end > asset_duration:
end = asset_duration
dur = end - start
# 边界钳制:start 不能超过素材时长
if start >= asset_duration:
start = max(0.0, asset_duration - MIN_TRIM_DURATION)
dur = asset_duration - start
end = asset_duration
# 保证 duration 不为负
if dur < 0:
dur = 0.0
return TrimConfig(start_time=start, end_time=end, duration=dur)
@property
def is_valid(self) -> bool:
"""裁剪是否有效(时长大于最小阈值)."""
return self.duration >= MIN_TRIM_DURATION
@property
def is_noop(self) -> bool:
"""是否等价于不裁剪(从0开始取全部)."""
return self.start_time <= 0 and self.duration <= 0
@property
def trim_from_start(self) -> bool:
"""是否从开头裁剪(start_time == 0."""
return self.start_time <= 0
@dataclass
class TrimSegment:
"""多段裁剪中的一段."""
segment_id: str # 段 ID(用于生成唯一标签)
trim: TrimConfig # 裁剪配置
order: int = 0 # 排序
@classmethod
def from_dict(cls, data: dict[str, Any], default_order: int = 0) -> TrimSegment:
"""从字典构造."""
return cls(
segment_id=str(data.get("segment_id", "") or f"seg_{default_order}"),
trim=TrimConfig(
start_time=float(data.get("start_time", 0) or 0),
end_time=float(data.get("end_time", 0) or 0),
duration=float(data.get("duration", 0) or 0),
),
order=int(data.get("order", default_order)),
)
class TrimEngine: class TrimEngine:
"""裁剪引擎 — 生成 FFmpeg trim / atrim 滤镜.""" """裁剪引擎 — 生成 FFmpeg trim / atrim 滤镜.
薄包装层,实际逻辑委托给 packages.domain.trim_config。
"""
@staticmethod @staticmethod
def build_video_trim_filter( def build_video_trim_filter(
@@ -175,38 +43,8 @@ class TrimEngine:
trim: TrimConfig, trim: TrimConfig,
output_label: str, output_label: str,
) -> str: ) -> str:
"""构建视频裁剪滤镜链. """构建视频裁剪滤镜链."""
return _build_video_trim_filter(input_label, trim, output_label)
Args:
input_label: 输入视频标签,如 "[0:v]"
trim: 裁剪配置(已解析钳制)
output_label: 输出视频标签,如 "[v0_trimmed]"
Returns:
FFmpeg filter 字符串,如 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]"
"""
if trim.is_noop:
# 不裁剪,直接直通(仅重置时间戳)
return f"{input_label}setpts=PTS-STARTPTS{output_label}"
parts: list[str] = []
# trim 滤镜参数
trim_args: list[str] = []
if trim.start_time > 0:
trim_args.append(f"start={trim.start_time:.3f}")
if trim.duration > 0:
trim_args.append(f"duration={trim.duration:.3f}")
elif trim.end_time > 0:
# end 用 duration 表示(start 到 end 的时长)
# 但 validate_and_resolve 后应该已经有 duration 了
pass
parts.append(f"trim={':'.join(trim_args)}")
parts.append("setpts=PTS-STARTPTS")
filter_str = f"{input_label}{','.join(parts)}{output_label}"
return filter_str
@staticmethod @staticmethod
def build_audio_trim_filter( def build_audio_trim_filter(
@@ -214,126 +52,18 @@ class TrimEngine:
trim: TrimConfig, trim: TrimConfig,
output_label: str, output_label: str,
) -> str: ) -> str:
"""构建音频裁剪滤镜链. """构建音频裁剪滤镜链."""
return _build_audio_trim_filter(input_label, trim, output_label)
Args:
input_label: 输入音频标签,如 "[0:a]"
trim: 裁剪配置(已解析钳制)
output_label: 输出音频标签,如 "[a0_trimmed]"
Returns:
FFmpeg filter 字符串,如 "[0:a]atrim=start=10:duration=5,asetpts=PTS-STARTPTS[a0_trimmed]"
"""
if trim.is_noop:
return f"{input_label}asetpts=PTS-STARTPTS{output_label}"
parts: list[str] = []
trim_args: list[str] = []
if trim.start_time > 0:
trim_args.append(f"start={trim.start_time:.3f}")
if trim.duration > 0:
trim_args.append(f"duration={trim.duration:.3f}")
parts.append(f"atrim={':'.join(trim_args)}")
parts.append("asetpts=PTS-STARTPTS")
filter_str = f"{input_label}{','.join(parts)}{output_label}"
return filter_str
@staticmethod @staticmethod
def resolve_segments( def resolve_segments(
segments: list[TrimSegment], segments: list[TrimSegment],
asset_duration: float, asset_duration: float,
) -> list[TrimSegment]: ) -> list[TrimSegment]:
"""解析并钳制多段裁剪配置,过滤无效段. """解析并钳制多段裁剪配置,过滤无效段."""
return _resolve_segments(segments, asset_duration)
Args:
segments: 原始段列表
asset_duration: 素材实际时长
Returns:
解析后的有效段列表,按 order 排序
"""
resolved: list[TrimSegment] = []
for i, seg in enumerate(segments):
resolved_trim = seg.trim.validate_and_resolve(asset_duration)
if not resolved_trim.is_valid:
logger.warning("裁剪段无效,跳过: segment_id=%s duration=%.3f", seg.segment_id, resolved_trim.duration)
continue
resolved.append(
TrimSegment(
segment_id=seg.segment_id,
trim=resolved_trim,
order=seg.order if seg.order >= 0 else i,
)
)
resolved.sort(key=lambda s: s.order)
return resolved
@staticmethod @staticmethod
def parse_segments_from_config(config: dict[str, Any] | None) -> list[TrimSegment]: def parse_segments_from_config(config: dict[str, Any] | None) -> list[TrimSegment]:
"""从 clip config 中解析多段裁剪配置. """从 clip config 中解析多段裁剪配置."""
return _parse_segments_from_config(config)
config 中支持:
- trim_segments: [ {segment_id, start_time, end_time, duration, order}, ... ]
- trim_start / trim_end / trim_duration: 单段裁剪(兼容旧格式)
"""
if not config:
return []
# 优先解析多段
raw_segments = config.get("trim_segments", [])
if raw_segments and isinstance(raw_segments, list):
segments = []
for i, raw in enumerate(raw_segments):
if isinstance(raw, dict):
segments.append(TrimSegment.from_dict(raw, default_order=i))
return segments
# 单段裁剪兼容:从 trim_start/trim_end/trim_duration 构造
has_single = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
if has_single:
seg = TrimSegment(
segment_id="main",
trim=TrimConfig(
start_time=float(config.get("trim_start", 0) or 0),
end_time=float(config.get("trim_end", 0) or 0),
duration=float(config.get("trim_duration", 0) or 0),
),
order=0,
)
return [seg]
return []
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None:
"""从 clip config 中提取单段裁剪配置.
兼容以下字段名:
- trim_start / trim_end / trim_duration
- start_time / end_time / duration(在 trim 子字典里)
"""
if not config:
return None
# trim 子字典
if "trim" in config and isinstance(config["trim"], dict):
return TrimConfig.from_dict(config["trim"])
# 扁平字段
has_any = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
if not has_any:
return None
data = {
"start_time": config.get("trim_start", 0),
"end_time": config.get("trim_end", 0),
"duration": config.get("trim_duration", 0),
}
return TrimConfig.from_dict(data)
@@ -52,6 +52,13 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr
from video_processing.tts_engine import TtsEngine from video_processing.tts_engine import TtsEngine
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX
from packages.domain.render_layer_utils import can_pass_through as _can_pass_through_pure
from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure
from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure
from packages.domain.render_layer_utils import clip_playback_speed as _clip_playback_speed_pure
from packages.domain.render_layer_utils import estimate_total_duration as _estimate_total_duration_pure
from packages.domain.render_layer_utils import resolve_layer_role as _resolve_layer_role_pure
from packages.domain.tts_config import TtsConfig from packages.domain.tts_config import TtsConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -107,47 +114,16 @@ class RenderResult:
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str: def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
"""根据 clip_type 和 config.role 确定图层角色。 """根据 clip_type 和 config.role 确定图层角色(向后兼容别名)
映射规则: 实际实现移至 packages.domain.render_layer_utils.resolve_layer_role。
intro / outro → "main"(按 order 排在首/尾)
overlay → "overlay"(画中画叠加,z=1
corner_voice → "corner_voice"(右上角小窗,z=1
background → "background"(全屏底图,z=0
b_roll → "broll"z=0
main + config.role=b_roll → "broll"
main (default) → "main"
""" """
role = config.get("role", "") return _resolve_layer_role_pure(clip_type, config)
if clip_type in ("intro", "outro"):
return "main"
if clip_type == "overlay":
return "overlay"
if clip_type == "corner_voice":
return "corner_voice"
if clip_type == "background":
return "background"
if clip_type == "b_roll":
return "broll"
# main type
if role == "b_roll":
return "broll"
if role == "audio":
return "audio"
return "main"
# ── 图层默认 z_index ───────────────────────────────────────────────────────── # ── 图层默认 z_index ─────────────────────────────────────────────────────────
_LAYER_Z_INDEX: dict[str, int] = { _LAYER_Z_INDEX: dict[str, int] = _IMPORTED_LAYER_Z_INDEX
"background": -1,
"broll": 0,
"main": 0,
"overlay": 1,
"corner_voice": 1,
"audio": 2,
}
# 图层默认 PiP 位置(相对输出画布的偏移) # 图层默认 PiP 位置(相对输出画布的偏移)
_PIP_SCALE = 0.25 # PiP 占主画面的比例 _PIP_SCALE = 0.25 # PiP 占主画面的比例
@@ -489,29 +465,9 @@ class UnifiedRenderService:
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float: def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
"""估算视频总时长(用于字幕等需要)。 """估算视频总时长(用于字幕等需要)。
取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算 实际实现移至 packages.domain.render_layer_utils.estimate_total_duration。
""" """
# 找主图层(第一个有视频内容的图层) return _estimate_total_duration_pure(layers, self.transition_duration)
main_layer = None
for role in ("main", "broll", "background"):
for layer in layers:
if layer.role == role:
main_layer = layer
break
if main_layer:
break
if not main_layer or not main_layer.clips:
return 0.0
total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in main_layer.clips)
# 减去转场重叠时间(粗略估算)
n_clips = len(main_layer.clips)
if n_clips > 1:
total -= (n_clips - 1) * self.transition_duration
return max(0.1, total)
def _maybe_generate_ass(self, video_duration: float) -> Path | None: def _maybe_generate_ass(self, video_duration: float) -> Path | None:
"""根据 plan.config 生成 ASS 字幕文件。 """根据 plan.config 生成 ASS 字幕文件。
@@ -1869,10 +1825,11 @@ class UnifiedRenderService:
@staticmethod @staticmethod
def _clip_effective_duration(clip: ResolvedClip) -> float: def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长(原速 trim 后时长).""" """计算 clip 的有效时长(原速 trim 后时长)
if clip.duration > 0:
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration 实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
return clip.actual_duration if clip.actual_duration > 0 else 0.0 """
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
# ── 画中画(PiP)相关方法 ────────────────────────────────────────────────── # ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
@@ -1969,17 +1926,20 @@ class UnifiedRenderService:
@staticmethod @staticmethod
def _clip_speed(clip: ResolvedClip) -> float: def _clip_speed(clip: ResolvedClip) -> float:
"""获取 clip 的播放速度,无效值回退到 1.0.""" """获取 clip 的播放速度,无效值回退到 1.0
speed = getattr(clip, "playback_speed", 1.0)
if not isinstance(speed, (int, float)) or speed <= 0: 实际实现移至 packages.domain.render_layer_utils.clip_playback_speed。
return 1.0 """
return float(speed) return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
@staticmethod @staticmethod
def _clip_adjusted_duration(clip: ResolvedClip) -> float: def _clip_adjusted_duration(clip: ResolvedClip) -> float:
"""计算调速后的 clip 实际时长(用于拼接计算).""" """计算调速后的 clip 实际时长(用于拼接计算)
base = UnifiedRenderService._clip_effective_duration(clip)
speed = UnifiedRenderService._clip_speed(clip) 实际实现移至 packages.domain.render_layer_utils.clip_adjusted_duration。
if abs(speed - 1.0) < 1e-6: """
return base return _clip_adjusted_duration_pure(
return base / speed clip.duration,
clip.actual_duration,
getattr(clip, "playback_speed", 1.0),
)
+26 -225
View File
@@ -6,135 +6,35 @@
- 9宫格位置 + 边距配置 - 9宫格位置 + 边距配置
- 透明度/大小缩放 - 透明度/大小缩放
- 滚动水印(跑马灯) - 滚动水印(跑马灯)
注:核心领域模型已抽离到 packages/domain/watermark_config.py
本模块保留薄包装层,确保向后兼容。
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from typing import Any from typing import Any
from packages.domain.watermark_config import (
WATERMARK_POSITIONS,
WatermarkConfig,
)
from packages.domain.watermark_config import ( # noqa: F401 — 向后兼容
build_image_watermark_filter as _build_image_watermark_filter,
)
from packages.domain.watermark_config import build_text_watermark_filter as _build_text_watermark_filter
from packages.domain.watermark_config import calc_position as _calc_position_base
from packages.domain.watermark_config import calc_scroll_x as _calc_scroll_x_base
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 9宫格位置枚举
WATERMARK_POSITIONS = {
"top_left": "左上",
"top_center": "中上",
"top_right": "右上",
"center_left": "左中",
"center": "中心",
"center_right": "右中",
"bottom_left": "左下",
"bottom_center": "中下",
"bottom_right": "右下",
}
@dataclass
class WatermarkConfig:
"""水印配置.
mode: "image" 图片水印 | "text" 文字水印
position: 9宫格位置
opacity: 透明度 0.0-1.0
scale: 缩放比例(图片水印),0.1-1.0
margin: 边距(像素)
scroll: 是否滚动(跑马灯)
scroll_speed: 滚动速度(像素/秒)
"""
mode: str = "text" # image | text
position: str = "bottom_right"
# 图片水印
image_path: str = "" # 本地图片路径
scale: float = 0.2 # 相对输出宽度的比例
opacity: float = 0.8 # 0.0-1.0
# 文字水印
text: str = ""
font_size: int = 24
font_color: str = "white"
font_path: str = "" # 字体文件路径
# 边距
margin_x: int = 20
margin_y: int = 20
# 滚动水印
scroll: bool = False
scroll_speed: int = 50 # 像素/秒
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> WatermarkConfig | None:
"""从字典构造,空配置返回 None(不加水印)."""
if not data:
return None
enabled = data.get("enabled", False)
if not enabled:
return None
mode = data.get("mode", "text")
# 图片模式需要 image_path;文字模式需要 text
if mode == "image":
image_path = data.get("image_path", "") or data.get("image", "") or ""
if not image_path:
logger.warning("图片水印缺少 image_path,跳过水印")
return None
elif mode == "text":
text = data.get("text", "") or ""
if not text:
logger.warning("文字水印缺少 text,跳过水印")
return None
position = data.get("position", "bottom_right")
if position not in WATERMARK_POSITIONS:
position = "bottom_right"
return cls(
mode=mode,
position=position,
image_path=str(data.get("image_path", data.get("image", "")) or ""),
scale=float(data.get("scale", 0.2)),
opacity=float(data.get("opacity", 0.8)),
text=str(data.get("text", "") or ""),
font_size=int(data.get("font_size", 24)),
font_color=str(data.get("font_color", "white")),
font_path=str(data.get("font_path", "") or ""),
margin_x=int(data.get("margin_x", 20)),
margin_y=int(data.get("margin_y", 20)),
scroll=bool(data.get("scroll", False)),
scroll_speed=int(data.get("scroll_speed", 50)),
)
def validate(self) -> tuple[bool, str]:
"""校验配置是否有效."""
if self.position not in WATERMARK_POSITIONS:
return False, f"不支持的位置: {self.position}"
if not (0.0 <= self.opacity <= 1.0):
return False, "透明度必须在 0-1 之间"
if self.mode == "image":
if not self.image_path:
return False, "图片水印缺少图片路径"
if not (0.01 <= self.scale <= 1.0):
return False, "缩放比例必须在 0.01-1.0 之间"
elif self.mode == "text":
if not self.text:
return False, "文字水印缺少文字内容"
if self.font_size <= 0:
return False, "字体大小必须大于 0"
else:
return False, f"不支持的水印模式: {self.mode}"
return True, ""
class WatermarkEngine: class WatermarkEngine:
"""水印引擎 — 生成 FFmpeg 水印滤镜.""" """水印引擎 — 生成 FFmpeg 水印滤镜.
薄包装层,实际逻辑委托给 packages.domain.watermark_config。
"""
@staticmethod @staticmethod
def calc_position( def calc_position(
@@ -150,27 +50,7 @@ class WatermarkEngine:
坐标系:左上角为 (0, 0) 坐标系:左上角为 (0, 0)
""" """
if position == "top_left": return _calc_position_base(position, output_width, output_height, wm_width, wm_height, margin_x, margin_y)
return margin_x, margin_y
elif position == "top_center":
return (output_width - wm_width) // 2, margin_y
elif position == "top_right":
return output_width - wm_width - margin_x, margin_y
elif position == "center_left":
return margin_x, (output_height - wm_height) // 2
elif position == "center":
return (output_width - wm_width) // 2, (output_height - wm_height) // 2
elif position == "center_right":
return output_width - wm_width - margin_x, (output_height - wm_height) // 2
elif position == "bottom_left":
return margin_x, output_height - wm_height - margin_y
elif position == "bottom_center":
return (output_width - wm_width) // 2, output_height - wm_height - margin_y
elif position == "bottom_right":
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
else:
# 默认右下角
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
@staticmethod @staticmethod
def calc_scroll_x(position: str, output_width: int, wm_width: int, speed: int) -> str: def calc_scroll_x(position: str, output_width: int, wm_width: int, speed: int) -> str:
@@ -178,12 +58,7 @@ class WatermarkEngine:
从右向左滚动(跑马灯效果) 从右向左滚动(跑马灯效果)
""" """
# x 从 W 到 -wm_width,整个宽度 + wm_width 的距离 return _calc_scroll_x_base(position, output_width, wm_width, speed)
# 使用 overlay 的 enable 表达式
# x = 'W - (t * speed)' → 不对,应该是持续滚动
# 标准跑马灯:x = -w + (t * speed) % (W + w)
# 但 FFmpeg overlay 支持表达式
return f"mod({output_width}-mod({speed}*t\\,{output_width}+{wm_width})"
@staticmethod @staticmethod
def build_image_watermark_filter( def build_image_watermark_filter(
@@ -208,51 +83,15 @@ class WatermarkEngine:
(filter_complex_str, input_args_list) (filter_complex_str, input_args_list)
input_args 是 ["-i", wm_image_path] 格式 input_args 是 ["-i", wm_image_path] 格式
""" """
# 计算水印尺寸(按输出宽度比例缩放) return _build_image_watermark_filter(
wm_width = int(output_width * config.scale) input_video_label,
wm_height = -1 # 保持比例 wm_image_path,
wm_filter = f"scale={wm_width}:{wm_height}"
# 透明度处理
if config.opacity < 1.0:
wm_filter += f",format=rgba,colorchannelmixer=aa={config.opacity}"
# 水印预处理标签
wm_pre_label = "[wm_scaled]"
# 计算位置
x, y = WatermarkEngine.calc_position(
config.position,
output_width, output_width,
output_height, output_height,
wm_width, output_label,
wm_width, # 高度未知,先用宽度估算 config,
config.margin_x,
config.margin_y,
) )
# 滚动水印
if config.scroll:
# 从右向左滚动:x = W - (t * speed) mod (W + wm_w)
# 使用 overlay 表达式
x_expr = f"{output_width}-mod({config.scroll_speed}*t\\,{output_width}+{wm_width}"
y_expr = str(y)
overlay_expr = f"x={x_expr}:y={y_expr}"
else:
overlay_expr = f"x={x}:y={y}"
# 构建滤镜
# 先缩放水印图
filter_parts = [
f"[1:v]{wm_filter}{wm_pre_label}",
f"{input_video_label}{wm_pre_label}overlay={overlay_expr}{output_label}",
]
filter_complex = ";".join(filter_parts)
input_args = ["-i", wm_image_path]
return filter_complex, input_args
@staticmethod @staticmethod
def build_text_watermark_filter( def build_text_watermark_filter(
input_video_label: str, input_video_label: str,
@@ -273,42 +112,4 @@ class WatermarkEngine:
Returns: Returns:
FFmpeg filter 字符串 FFmpeg filter 字符串
""" """
# 转义文字中的特殊字符 return _build_text_watermark_filter(input_video_label, output_label, config, output_width, output_height)
text = config.text.replace(":", "\\:").replace("'", "\\'")
# 字体配置
font_config = []
if config.font_path:
font_path_escaped = config.font_path.replace(":", "\\:").replace("'", "\\'")
font_config.append(f"fontfile='{font_path_escaped}'")
font_config.append(f"fontsize={config.font_size}")
font_config.append(f"fontcolor={config.font_color}@{config.opacity}")
# 估算文字宽高(粗略估算,用于位置计算)
# 每个汉字约等于 font_size 宽高
approx_w = len(config.text) * config.font_size
approx_h = config.font_size
# 位置计算
x, y = WatermarkEngine.calc_position(
config.position,
output_width,
output_height,
approx_w,
approx_h,
config.margin_x,
config.margin_y,
)
# 滚动水印
if config.scroll:
x_expr = f"w-mod({config.scroll_speed}*t\\,W+w)"
pos_config = [f"x={x_expr}", f"y={y}"]
else:
pos_config = [f"x={x}", f"y={y}"]
# 组装 drawtext
drawtext_parts = [f"text='{text}'"] + font_config + pos_config
drawtext = "drawtext=" + ":".join(drawtext_parts)
return f"{input_video_label}{drawtext}{output_label}"
@@ -12,10 +12,8 @@ import json
import logging import logging
import os import os
import tempfile import tempfile
from dataclasses import dataclass, field
import numpy as np import numpy as np
from PIL import Image
from packages.domain.classification import AssetClassification from packages.domain.classification import AssetClassification
@@ -26,7 +24,6 @@ from .asset_quality_scoring import (
MotionAnalysis, MotionAnalysis,
QualityScore, QualityScore,
VideoInfo, VideoInfo,
calculate_category_scores,
calculate_quality_score, calculate_quality_score,
classify_from_analysis, classify_from_analysis,
) )
+9 -10
View File
@@ -20,19 +20,18 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from video_processing.ffmpeg_utils import probe_duration
from worker_app.celery_app import celery_app from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal from worker_app.db import SessionLocal
from worker_app.tasks.generation_plan_builder import VirtualClip as _VirtualClip
from worker_app.tasks.generation_plan_builder import VirtualPlan as _VirtualPlan
from worker_app.tasks.generation_plan_builder import apply_template_clip_effects as _apply_template_clip_effects
from worker_app.tasks.generation_plan_builder import build_error_info as _build_error_info
from worker_app.tasks.generation_plan_builder import (
extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs,
)
from packages.domain.bgm_utils import merge_bgm_config from packages.domain.bgm_utils import merge_bgm_config
from video_processing.ffmpeg_utils import probe_duration
from worker_app.tasks.generation_plan_builder import (
VirtualPlan as _VirtualPlan,
VirtualClip as _VirtualClip,
build_error_info as _build_error_info,
extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs,
apply_template_clip_effects as _apply_template_clip_effects,
build_clips_by_mode,
)
OUTPUT_WIDTH = 1280 OUTPUT_WIDTH = 1280
OUTPUT_HEIGHT = 720 OUTPUT_HEIGHT = 720
@@ -119,7 +118,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
# ── 共享工具模块导入 ────────────────────────────────────────────────────────── # ── 共享工具模块导入 ──────────────────────────────────────────────────────────
from video_processing.dedup_helpers import create_video_record_and_dedup from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
from video_processing.oss_helpers import ( from video_processing.oss_helpers import (
download_asset, download_asset,
get_signed_download_url, get_signed_download_url,
@@ -13,8 +13,7 @@ from __future__ import annotations
import traceback import traceback
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from typing import Any
from typing import Any, Optional
# ── 数据类 ─────────────────────────────────────────────────────────────────── # ── 数据类 ───────────────────────────────────────────────────────────────────
+2 -92
View File
@@ -14,70 +14,12 @@ from packages.adapters.sqlalchemy_impl import (
SQLAlchemyIngestJobRepository, SQLAlchemyIngestJobRepository,
) )
from packages.domain import Asset, AssetStatus, IngestJobStatus from packages.domain import Asset, AssetStatus, IngestJobStatus
from packages.domain.media_validation import is_valid_media as _is_valid_media
from packages.domain.media_validation import safe_parse_fps as _safe_parse_fps
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体
MIN_VIDEO_FILE_SIZE = 1024 # 1KB
MIN_AUDIO_FILE_SIZE = 100 # 100B
MIN_IMAGE_FILE_SIZE = 100 # 100B
# 支持的视频编码格式(白名单,尽可能放宽)
# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested
SUPPORTED_VIDEO_CODECS = {
"h264",
"avc1",
"avc", # H.264 / AVC
"hevc",
"h265",
"hev1",
"hvc1", # H.265 / HEVC
"vp9",
"vp09", # VP9
"av1",
"av01", # AV1
"vp8",
"vp08", # VP8
"mpeg4",
"mp4v", # MPEG-4
"mpeg2video",
"mpg2", # MPEG-2
"wmv2",
"wmv1",
"vc1", # WMV / VC-1
"flv1",
"flv",
"vp6f", # Flash / FLV
"theora",
"ogg", # Theora
"prores",
"prores_ks",
"apcn",
"apch",
"apco",
"apcs",
"ap4h",
"ap4x", # Apple ProRes
"dnxhd",
"dnxhr", # DNxHD / DNxHR
}
def _safe_parse_fps(fps_str: str) -> float:
"""Safely parse fps from a fraction string like \"30/1\" or \"30000/1001\"."""
try:
if "/" in fps_str:
num, den = fps_str.split("/", 1)
den_val = float(den)
if den_val == 0:
return 0.0
return float(num) / den_val
return float(fps_str)
except (ValueError, ZeroDivisionError):
return 0.0
def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]: def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
""" """
提取媒体文件的元数据。 提取媒体文件的元数据。
@@ -207,38 +149,6 @@ def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
return metadata, success return metadata, success
def _is_valid_media(metadata: dict, media_type: str) -> bool:
"""根据元数据判断文件是否为有效媒体文件。
Args:
metadata: extract_media_metadata 返回的元数据
media_type: 媒体类型
Returns:
True 表示文件有效
"""
size = int(metadata.get("size_bytes", 0))
if media_type == "video":
duration = float(metadata.get("duration", 0))
if size < MIN_VIDEO_FILE_SIZE or duration <= 0:
return False
# 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许
# 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截
codec = str(metadata.get("codec", "")).lower()
if codec and codec not in SUPPORTED_VIDEO_CODECS:
logger.info("检测到非白名单视频编码 %s,仍允许 ingested,渲染层会统一转码", codec)
return True
if media_type == "audio":
duration = float(metadata.get("duration", 0))
return size >= MIN_AUDIO_FILE_SIZE and duration > 0
if media_type == "image":
width = int(metadata.get("width", 0))
height = int(metadata.get("height", 0))
return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0
return False
@celery_app.task(name="worker.ingest_asset") @celery_app.task(name="worker.ingest_asset")
def ingest_asset(job_id: str) -> dict: def ingest_asset(job_id: str) -> dict:
""" """
+249
View File
@@ -0,0 +1,249 @@
"""AI 响应解析纯逻辑模块.
抽离自 ai_service.py 的解析函数,方便单测覆盖,同时保持向后兼容。
包括:
- 标题列表解析(JSON/编号/换行/破折号格式)
- 语义匹配结果解析(多种JSON格式)
- 标题降级生成
- 关键词匹配降级
"""
from __future__ import annotations
import json
import math
import random
import re
from typing import Any
# ── 标题解析 ──────────────────────────────────────────────────────────────────
def parse_titles_from_response(content: str) -> list[str]:
"""从模型返回中解析标题列表.
支持多种返回格式:
- JSON 数组: ["标题1", "标题2"]
- 编号列表: 1. 标题1 / 2. 标题2
- 换行分隔: 标题1\n标题2
- 带破折号: - 标题1
"""
if not content:
return []
# 尝试解析 JSON
try:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
if isinstance(data, list):
return [str(item).strip() for item in data if str(item).strip()]
if isinstance(data, dict) and "titles" in data:
titles = data["titles"]
if isinstance(titles, list):
return [str(t).strip() for t in titles if str(t).strip()]
except (json.JSONDecodeError, ValueError):
pass
# 尝试按行解析
titles: list[str] = []
for line in content.strip().split("\n"):
line = line.strip()
if not line:
continue
# 去掉编号前缀 "1. " "1、" "1"
line = re.sub(r"^[\d]+[\.、\)]\s*", "", line)
# 去掉破折号前缀 "- " "• "
line = re.sub(r"^[-•·]\s*", "", line)
# 去掉引号
line = line.strip('"').strip("'").strip("「」")
if line and len(line) < 100: # 过滤过长的行
titles.append(line)
return titles
# ── 语义匹配解析 ────────────────────────────────────────────────────────────
def parse_semantic_match_response(
content: str,
asset_ids: list[str],
) -> dict[str, float] | None:
"""从模型返回中解析素材匹配度.
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
score 范围 0-1,自动截断到 [0, 1]。
"""
if not content:
return None
try:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
result: dict[str, float] = {}
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
if isinstance(data, dict):
if "matches" in data and isinstance(data["matches"], list):
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
for item in data["matches"]:
if isinstance(item, dict):
aid = item.get("asset_id") or item.get("id")
score = item.get("score", 0)
if aid and isinstance(score, (int, float)):
result[str(aid)] = max(0.0, min(1.0, float(score)))
else:
for key, value in data.items():
if isinstance(value, (int, float)):
result[str(key)] = max(0.0, min(1.0, float(value)))
# 格式3: [{"asset_id": "...", "score": 0.8}]
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
aid = item.get("asset_id") or item.get("id")
score = item.get("score", 0)
if aid and isinstance(score, (int, float)):
result[str(aid)] = max(0.0, min(1.0, float(score)))
# 至少一半素材有评分才算成功
if asset_ids and len(result) >= max(1, len(asset_ids) // 2):
return result
# 没有 asset_ids 时,只要有结果就返回
if not asset_ids and result:
return result
except (json.JSONDecodeError, ValueError):
pass
return None
# ── 标题降级生成 ─────────────────────────────────────────────────────────────
def generate_titles_fallback(
description: str,
style_info: dict[str, Any],
count: int = 5,
) -> list[str]:
"""本地降级:基于模板规则生成标题.
Args:
description: 视频内容描述
style_info: 标题风格配置 {"name": ..., "examples": [...]}
count: 生成数量
"""
examples = style_info.get("examples", [])
# 从描述中提取关键词(取前几个词)
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
keyword = keywords[0] if keywords else "精彩内容"
# 基于模板生成
example_0 = examples[0][:10] + "..." if examples else "必看"
example_1 = examples[1] if len(examples) > 1 else "你不知道的事"
templates = [
f"{keyword}{example_0}",
f"{keyword}{example_1}",
f"关于{keyword},你不知道的3件事",
f"{keyword}入门指南,新手必看",
f"深度解析:{keyword}背后的秘密",
f"{keyword}怎么做?手把手教你",
f"干货分享 | {keyword}全攻略",
f"建议收藏:{keyword}实用技巧",
f"{keyword}避坑指南,别再踩雷了",
f"一分钟搞懂{keyword}",
]
random.shuffle(templates)
return templates[: min(count, len(templates))]
# ── 关键词匹配降级 ───────────────────────────────────────────────────────────
def keyword_match_fallback(
description: str,
assets: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""本地降级:基于关键词的简单匹配.
计算描述中的关键词与素材名称/标签/描述的重叠度,
作为匹配度评分。0-1分。
"""
# 提取关键词(中文按2字以上片段,英文按单词)
desc = description.lower()
keywords: set[str] = set()
# 英文单词
for word in re.findall(r"[a-zA-Z]{3,}", desc):
keywords.add(word)
# 中文2-4字片段
for i in range(len(desc)):
for j in range(i + 2, min(i + 5, len(desc) + 1)):
fragment = desc[i:j]
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
keywords.add(fragment)
if not keywords:
# 没有关键词时给所有素材中等分数
results = []
for asset in assets:
new_asset = dict(asset)
new_asset["match_score"] = 0.5
new_asset["match_reason"] = "fallback_default"
results.append(new_asset)
return results
results = []
for asset in assets:
# 组合素材的文本信息:名称 + 标签 + 描述
asset_text_parts = [
str(asset.get("name", "")).lower(),
" ".join(str(t) for t in asset.get("tags", [])).lower(),
str(asset.get("description", "")).lower(),
]
asset_text = " | ".join(asset_text_parts)
# 计算匹配度:命中关键词占比 + 稀有关键词加权
hit_count = 0
hit_keywords: list[str] = []
for kw in keywords:
if kw in asset_text:
hit_count += 1
hit_keywords.append(kw)
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
# 名称命中加分(名称匹配更重要)
name = str(asset.get("name", "")).lower()
name_hits = sum(1 for kw in hit_keywords if kw in name)
name_bonus = min(0.2, name_hits * 0.05)
score = min(1.0, base_score * 0.8 + name_bonus)
score = round(score, 3)
new_asset = dict(asset)
new_asset["match_score"] = score
new_asset["match_reason"] = "fallback_keyword"
results.append(new_asset)
# 按匹配度降序
results.sort(key=lambda x: x["match_score"], reverse=True)
return results
+306
View File
@@ -0,0 +1,306 @@
"""ASS 字幕构建领域模型 — 纯逻辑,无文件IO依赖.
抽离自 render_subtitles.py,包含:
- 颜色转换(hex → ASS &HBBGGRR
- 位置对齐映射
- ASS Style 行构建
- 文本转义
- 时间格式化
- 完整 ASS 内容生成(返回字符串,不写文件)
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# Title/Subtitle 默认边距(像素)
TITLE_MARGIN_TOP = 60
TITLE_MARGIN_BOTTOM = 60
TITLE_MARGIN_SIDE = 40
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
def hex_to_ass_color(hex_color: str) -> str:
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式.
Args:
hex_color: HEX 颜色字符串,支持 #RRGGBB 或 RRGGBB 格式
Returns:
ASS 格式颜色,如 &H0000FF(红色)
"""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H000000"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H{b.upper()}{g.upper()}{r.upper()}"
# ── 位置对齐 ──────────────────────────────────────────────────────────────────
def position_to_ass_alignment(position: str) -> int:
"""将文字位置映射为 ASS \\an 对齐编号.
ASS 对齐编号(数字小键盘布局):
7 8 9
4 5 6
1 2 3
Args:
position: 位置字符串 top/center/bottom
Returns:
ASS 对齐编号,默认 8(顶部居中)
"""
mapping = {
"top": 8,
"center": 5,
"bottom": 2,
}
return mapping.get(position, 8)
# ── Style 行构建 ──────────────────────────────────────────────────────────────
def build_ass_style(
style_name: str,
*,
font_name: str = "思源黑体",
font_size: int = 48,
primary_color: str = "&H00FFFFFF",
outline_color: str = "&H00000000",
outline_width: float = 1.0,
shadow_blur: float = 0.0,
shadow_offset: tuple[int, int] = (0, 0),
bold: bool = False,
italic: bool = False,
alignment: int = 8,
margin_v: int = 60,
margin_l: int = 40,
margin_r: int = 40,
) -> str:
"""构建 ASS Style 行.
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Args:
style_name: 样式名称
font_name: 字体名称
font_size: 字体大小
primary_color: 主色(文字颜色)
outline_color: 描边颜色
outline_width: 描边宽度
shadow_blur: 阴影模糊度(>0 时启用阴影)
shadow_offset: 阴影偏移 (x, y)
bold: 是否粗体
italic: 是否斜体
alignment: 对齐方式(ASS \an 编号)
margin_v: 垂直边距
margin_l: 左边距
margin_r: 右边距
Returns:
完整的 Style: 行字符串
"""
bold_val = -1 if bold else 0
italic_val = -1 if italic else 0
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow
back_color = primary_color
# Shadow 深度:shadow_offset[1] 作为纵向偏移
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
return (
f"Style: {style_name},{font_name},{font_size},{primary_color},"
f"&H000000FF,{outline_color},{back_color},"
f"{bold_val},{italic_val},0,0,100,100,0,0,"
f"1,{outline_width},{shadow_depth},{alignment},"
f"{margin_l},{margin_r},{margin_v},1"
)
# ── 文本转义 ──────────────────────────────────────────────────────────────────
def escape_ass_text(text: str) -> str:
r"""转义 ASS 文本中的特殊字符.
ASS 中换行用 \N(硬换行)或 \n(软换行),
大括号 {} 用于覆盖样式,需要转义.
Args:
text: 原始文本
Returns:
转义后的 ASS 文本
"""
# 将实际换行转为 ASS 硬换行
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
# 转义大括号(ASS 用它做样式覆盖标签)
text = text.replace("{", "(").replace("}", ")")
return text
# ── 时间格式化 ────────────────────────────────────────────────────────────────
def format_ass_time(seconds: float) -> str:
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc.
Args:
seconds: 秒数
Returns:
ASS 格式时间,如 "1:23:45.67"
"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
def build_ass_content(
*,
video_width: int,
video_height: int,
video_duration: float,
title_text: str = "",
title_config: dict[str, Any] | None = None,
subtitle_text: str = "",
subtitle_config: dict[str, Any] | None = None,
) -> str:
"""生成 ASS 字幕文件内容(纯字符串,不写文件).
支持 Title(标题)和 Subtitle(字幕)两种字幕类型,
各自可独立配置样式、位置和内容.
Args:
video_width: 视频宽度(用于 ASS PlayResX
video_height: 视频高度(用于 ASS PlayResY
video_duration: 视频总时长(秒),字幕显示整个时长
title_text: 标题文本
title_config: 标题样式配置
subtitle_text: 字幕文本
subtitle_config: 字幕样式配置
Returns:
完整的 ASS 文件内容字符串;无字幕时返回空字符串
"""
title_config = title_config or {}
subtitle_config = subtitle_config or {}
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
if not title_enabled and not subtitle_enabled:
return ""
styles: list[str] = []
events: list[str] = []
# ── Title 样式与事件 ──────────────────────────────────────────────────
if title_enabled:
title_color = hex_to_ass_color(title_config.get("color", "#ffffff"))
title_stroke = title_config.get("stroke", {}) or {}
title_shadow = title_config.get("shadow", {}) or {}
stroke_color = hex_to_ass_color(title_stroke.get("color", "#000000"))
stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
shadow_offset = (
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
)
title_alignment = position_to_ass_alignment(title_config.get("position", "top"))
styles.append(
build_ass_style(
"TitleStyle",
font_name=title_config.get("font", "思源黑体"),
font_size=int(title_config.get("size", 48)),
primary_color=title_color,
outline_color=stroke_color,
outline_width=stroke_width,
shadow_blur=shadow_blur,
shadow_offset=shadow_offset,
bold=bool(title_config.get("bold", True)),
italic=bool(title_config.get("italic", False)),
alignment=title_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
)
safe_title_text = escape_ass_text(title_text)
events.append(
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
)
# ── Subtitle 样式与事件 ───────────────────────────────────────────────
if subtitle_enabled:
sub_color = hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
sub_alignment = position_to_ass_alignment(subtitle_config.get("position", "bottom"))
styles.append(
build_ass_style(
"SubtitleStyle",
font_name=subtitle_config.get("font", "思源黑体"),
font_size=int(subtitle_config.get("size", 24)),
primary_color=sub_color,
outline_color="&H00000000",
outline_width=1.0,
shadow_blur=0.0,
shadow_offset=(0, 0),
bold=False,
italic=False,
alignment=sub_alignment,
margin_v=TITLE_MARGIN_BOTTOM,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
)
safe_subtitle_text = escape_ass_text(subtitle_text)
events.append(
"Dialogue: 0,0:00:00.00,"
f"{format_ass_time(video_duration)},"
"SubtitleStyle,,0,0,0,,"
f"{safe_subtitle_text}"
)
# ── 组装 ASS 文件 ─────────────────────────────────────────────────────
return f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
PlayResY: {video_height}
ScaledBorderAndShadow: yes
WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
{chr(10).join(styles)}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
"""
+214
View File
@@ -0,0 +1,214 @@
"""多轨道音频配置领域模型 — 纯逻辑,无外部依赖.
抽离自 multi_track_mixer.py 的数据类、常量和纯逻辑函数,
方便单测覆盖,同时保持向后兼容。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
TRACK_TYPE_BGM = "bgm" # 背景音乐
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
TRACK_TYPE_SFX = "sfx" # 音效
TRACK_TYPE_AMBIENT = "ambient" # 环境音
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
# 各轨道默认音量(相对主音频)
DEFAULT_VOLUMES = {
TRACK_TYPE_MAIN: 1.0,
TRACK_TYPE_BGM: 0.3,
TRACK_TYPE_VOICEOVER: 1.0,
TRACK_TYPE_SFX: 0.7,
TRACK_TYPE_AMBIENT: 0.2,
}
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
_VALID_TRACK_TYPES = {
TRACK_TYPE_MAIN,
TRACK_TYPE_BGM,
TRACK_TYPE_VOICEOVER,
TRACK_TYPE_SFX,
TRACK_TYPE_AMBIENT,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class AudioTrack:
"""单条音频轨道配置."""
track_id: str = "" # 轨道唯一标识
track_type: str = TRACK_TYPE_SFX # 轨道类型
audio_path: str = "" # 音频文件路径
volume: float = 1.0 # 音量 0.0 ~ 2.0
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
duration: float = 0.0 # 持续时长(0表示到文件末尾)
enabled: bool = True # 是否启用
@classmethod
def from_dict(cls, track: dict) -> "AudioTrack":
"""从字典创建 AudioTrack,带安全类型转换."""
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
try:
volume = float(track.get("volume", default_vol))
except (TypeError, ValueError):
volume = default_vol
volume = max(0.0, min(2.0, volume))
try:
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
except (TypeError, ValueError):
fade_in = 0.0
try:
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
except (TypeError, ValueError):
fade_out = 0.0
try:
start_time = max(0.0, float(track.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(track.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
track_id=str(track.get("track_id", "")),
track_type=track_type,
audio_path=str(track.get("audio_path", "")),
volume=volume,
fade_in=fade_in,
fade_out=fade_out,
start_time=start_time,
duration=duration,
enabled=bool(track.get("enabled", True)),
)
def validate(self) -> tuple[bool, str]:
"""校验配置合法性,返回 (是否合法, 错误信息)."""
if not self.audio_path:
return False, "audio_path不能为空"
if self.volume < 0.0 or self.volume > 2.0:
return False, f"volume必须在0-2之间: {self.volume}"
if self.fade_in < 0:
return False, f"fade_in不能为负数: {self.fade_in}"
if self.fade_out < 0:
return False, f"fade_out不能为负数: {self.fade_out}"
if self.start_time < 0:
return False, f"start_time不能为负数: {self.start_time}"
if self.duration < 0:
return False, f"duration不能为负数: {self.duration}"
return True, ""
@property
def is_effective(self) -> bool:
"""是否为有效轨道(启用+有路径)."""
return self.enabled and bool(self.audio_path)
@dataclass
class MultiTrackMixConfig:
"""多轨道混音配置."""
tracks: list[AudioTrack] = field(default_factory=list)
master_volume: float = 1.0 # 主输出音量
normalize: bool = True # 是否自动归一化补偿
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
@classmethod
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
"""从 plan.config.audio_tracks 字典创建配置."""
if not config or not isinstance(config, dict):
return cls()
tracks_raw = config.get("tracks", [])
tracks: list[AudioTrack] = []
if isinstance(tracks_raw, list):
for t in tracks_raw:
if isinstance(t, dict) and t.get("audio_path"):
try:
track = AudioTrack.from_dict(t)
if track.enabled and track.audio_path:
tracks.append(track)
except Exception:
logger.warning("[multi-track] skip invalid track config: %s", t)
continue
try:
master_volume = float(config.get("master_volume", 1.0))
master_volume = max(0.0, min(2.0, master_volume))
except (TypeError, ValueError):
master_volume = 1.0
try:
max_output_volume = float(config.get("max_output_volume", 1.5))
except (TypeError, ValueError):
max_output_volume = 1.5
return cls(
tracks=tracks,
master_volume=master_volume,
normalize=bool(config.get("normalize", True)),
max_output_volume=max_output_volume,
)
@property
def has_effect(self) -> bool:
"""是否有有效轨道需要混音."""
return len([t for t in self.tracks if t.is_effective]) > 0
@property
def effective_track_count(self) -> int:
"""有效轨道数量."""
return len([t for t in self.tracks if t.is_effective])
@property
def main_tracks(self) -> list[AudioTrack]:
"""主音轨列表."""
return [t for t in self.tracks if t.track_type == TRACK_TYPE_MAIN and t.is_effective]
@property
def bgm_tracks(self) -> list[AudioTrack]:
"""BGM轨道列表."""
return [t for t in self.tracks if t.track_type == TRACK_TYPE_BGM and t.is_effective]
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
def is_valid_audio_extension(filename: str) -> bool:
"""检查文件扩展名是否为支持的音频格式."""
ext = Path(filename).suffix.lower()
return ext in ALLOWED_AUDIO_EXTENSIONS
def clamp_volume(volume: float, min_vol: float = 0.0, max_vol: float = 2.0) -> float:
"""限制音量在合法范围内."""
return max(min_vol, min(max_vol, volume))
+287
View File
@@ -0,0 +1,287 @@
"""绿幕抠像配置领域模型 — 纯逻辑,无FFmpeg依赖.
抽离自 chroma_key_engine.py,包含:
- ChromaKeyConfig 数据类(解析/钳制/效果判断)
- 预设配置(绿幕/蓝幕/红幕等)
- 颜色归一化
- colorkey / chromakey 滤镜构建
- 便捷函数(apply_chroma_key_if_needed
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# ── 预设配置 ──────────────────────────────────────────────────────────────────
# 常见绿幕/蓝幕预设
CHROMA_KEY_PRESETS: dict[str, dict[str, Any]] = {
"green_screen": {
"key_color": "#00FF00",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.5,
},
"blue_screen": {
"key_color": "#0000FF",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.5,
},
"red_screen": {
"key_color": "#FF0000",
"similarity": 0.3,
"blend": 0.1,
"spill_suppress": 0.0,
},
"precise_green": {
"key_color": "#00FF00",
"similarity": 0.2,
"blend": 0.05,
"spill_suppress": 0.3,
},
"soft_green": {
"key_color": "#00FF00",
"similarity": 0.45,
"blend": 0.2,
"spill_suppress": 0.5,
},
}
VALID_PRESETS = set(CHROMA_KEY_PRESETS.keys())
# 参数范围
MIN_SIMILARITY = 0.01
MAX_SIMILARITY = 1.0
MIN_BLEND = 0.0
MAX_BLEND = 1.0
MIN_SPILL_SUPPRESS = 0.0
MAX_SPILL_SUPPRESS = 1.0
# 默认值
DEFAULT_KEY_COLOR = "#00FF00"
DEFAULT_SIMILARITY = 0.3
DEFAULT_BLEND = 0.1
DEFAULT_SPILL_SUPPRESS = 0.0
# ── 配置模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ChromaKeyConfig:
"""绿幕抠像配置.
Attributes:
enabled: 是否启用抠像
key_color: 要抠除的颜色,支持 hex 格式(如 "#00FF00")或颜色名
similarity: 颜色相似度阈值 0.01~1.0,值越大抠除范围越大
blend: 边缘平滑/混合度 0.0~1.0,值越大边缘越柔和
spill_suppress: 溢色抑制 0.0~1.0,减少边缘的绿幕反光
"""
enabled: bool = False
key_color: str = DEFAULT_KEY_COLOR
similarity: float = DEFAULT_SIMILARITY
blend: float = DEFAULT_BLEND
spill_suppress: float = DEFAULT_SPILL_SUPPRESS
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ChromaKeyConfig:
"""从字典解析配置,参数越界自动钳制."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
key_color = str(data.get("key_color", DEFAULT_KEY_COLOR)).strip()
def _safe_float(val: Any, default: float) -> float:
try:
return float(val)
except (TypeError, ValueError):
return default
similarity = _safe_float(data.get("similarity", DEFAULT_SIMILARITY), DEFAULT_SIMILARITY)
blend = _safe_float(data.get("blend", DEFAULT_BLEND), DEFAULT_BLEND)
spill_suppress = _safe_float(data.get("spill_suppress", DEFAULT_SPILL_SUPPRESS), DEFAULT_SPILL_SUPPRESS)
# 钳制到合法范围
similarity = max(MIN_SIMILARITY, min(MAX_SIMILARITY, similarity))
blend = max(MIN_BLEND, min(MAX_BLEND, blend))
spill_suppress = max(MIN_SPILL_SUPPRESS, min(MAX_SPILL_SUPPRESS, spill_suppress))
return cls(
enabled=True,
key_color=key_color,
similarity=similarity,
blend=blend,
spill_suppress=spill_suppress,
)
@classmethod
def from_preset(cls, preset_name: str) -> ChromaKeyConfig | None:
"""从预设名称创建配置."""
preset = CHROMA_KEY_PRESETS.get(preset_name)
if not preset:
return None
return cls(
enabled=True,
key_color=preset["key_color"],
similarity=preset["similarity"],
blend=preset["blend"],
spill_suppress=preset["spill_suppress"],
)
def has_effect(self) -> bool:
"""判断是否有实际抠像效果."""
return self.enabled and self.similarity > 0
def validate(self) -> tuple[bool, str]:
"""校验配置是否有效."""
if not self.enabled:
return True, ""
if not self.key_color:
return False, "key_color 不能为空"
if not (MIN_SIMILARITY <= self.similarity <= MAX_SIMILARITY):
return False, f"similarity 必须在 {MIN_SIMILARITY}~{MAX_SIMILARITY} 之间"
if not (MIN_BLEND <= self.blend <= MAX_BLEND):
return False, f"blend 必须在 {MIN_BLEND}~{MAX_BLEND} 之间"
if not (MIN_SPILL_SUPPRESS <= self.spill_suppress <= MAX_SPILL_SUPPRESS):
return False, f"spill_suppress 必须在 {MIN_SPILL_SUPPRESS}~{MAX_SPILL_SUPPRESS} 之间"
return True, ""
# ── 颜色归一化 ────────────────────────────────────────────────────────────────
def normalize_color(color_str: str) -> str:
"""将颜色字符串转为 FFmpeg colorkey 接受的格式.
支持:
- "#RRGGBB" / "#RRGGBBAA" → 0xRRGGBB
- "0xRRGGBB" → 直接使用
- 颜色名(green/blue/red/black/white 等)→ 直接透传
"""
color = color_str.strip()
# hex 格式
hex_match = re.match(r"^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$", color)
if hex_match:
return f"0x{hex_match.group(1).upper()}"
# 已经是 0x 格式
if color.lower().startswith("0x"):
return color.upper()
# 颜色名直接透传(FFmpeg 支持常见颜色名)
return color
# ── 滤镜构建 ────────────────────────────────────────────────────────────────
def build_colorkey_filter(
config: ChromaKeyConfig,
input_label: str,
output_label: str,
) -> str:
"""构建 colorkey 滤镜字符串.
Args:
config: 抠像配置
input_label: 输入标签,如 "[0:v]""[v0]"
output_label: 输出标签,如 "[ck0]"
Returns:
FFmpeg 滤镜字符串
"""
if not config.has_effect():
return f"{input_label}copy{output_label}"
color = normalize_color(config.key_color)
similarity = config.similarity
blend = config.blend
# 基础 colorkey 滤镜
parts = [f"colorkey=color={color}:similarity={similarity}:blend={blend}"]
# 溢色抑制(通过 colorchannelmixer 降低绿色通道增益)
if config.spill_suppress > 0:
spill = config.spill_suppress
g_gain = max(0.3, 1.0 - spill * 0.7)
r_gain = 1.0 + spill * 0.15
b_gain = 1.0 + spill * 0.15
parts.append(f"colorchannelmixer=rr={r_gain}:gg={g_gain}:bb={b_gain}:aa=1")
return f"{input_label}{','.join(parts)}{output_label}"
def build_chromakey_filter(
config: ChromaKeyConfig,
input_label: str,
output_label: str,
) -> str:
"""使用 chromakey 滤镜(更高级的版本,支持更多参数).
注意:并非所有 FFmpeg 版本都支持 chromakey 滤镜,
优先使用 colorkey(兼容性更好)。
"""
if not config.has_effect():
return f"{input_label}copy{output_label}"
color = normalize_color(config.key_color)
similarity = config.similarity
blend = config.blend
return f"{input_label}chromakey=color={color}:similarity={similarity}:blend={blend}{output_label}"
# ── 工具函数 ────────────────────────────────────────────────────────────────
def apply_chroma_key_if_needed(
clip_config: dict[str, Any] | None,
input_label: str,
output_label: str,
) -> str | None:
"""便捷函数:根据 clip 配置判断是否需要应用绿幕抠像.
Args:
clip_config: clip 的 config 字典
input_label: 输入标签
output_label: 输出标签
Returns:
滤镜字符串,不需要抠像时返回 None
"""
if not clip_config:
return None
chroma_key_data = clip_config.get("chroma_key")
if not chroma_key_data:
return None
try:
config = ChromaKeyConfig.from_dict(chroma_key_data)
if not config.has_effect():
return None
return build_colorkey_filter(config, input_label, output_label)
except Exception as e:
logger.warning("[chroma-key] 应用抠像失败,跳过: %s", e)
return None
def get_preset_names() -> list[str]:
"""获取所有预设名称列表."""
return sorted(list(CHROMA_KEY_PRESETS.keys()))
+267
View File
@@ -0,0 +1,267 @@
"""片段操作工具 — EditPlanClip 分割/合并等纯逻辑操作。
从 edit_plan_service.py 抽离的纯函数集合,专门负责:
- 片段分割:将一个片段从指定位置拆分为两个
- 片段合并:将多个连续片段合并为一个
- Order 重排计算
所有函数均为纯函数,不依赖数据库或外部 IO。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_SPLIT_DURATION = 5.0
ROUND_PRECISION = 3
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SplitResult:
"""片段分割结果。"""
left_duration: float
right_duration: float
right_start_time: float
left_trim_end: float
right_trim_start: float
@dataclass(frozen=True)
class MergeResult:
"""片段合并结果。"""
total_duration: float
merged_text: str
merged_config: dict[str, Any]
first_order: int
shift_amount: int
# ── 分割 ──────────────────────────────────────────────────────────────────────
def validate_split_time(split_time: float, duration: float) -> None:
"""校验分割时间是否合法。
Args:
split_time: 分割点(秒)
duration: 原片段时长(秒)
Raises:
ValueError: 分割时间不在 (0, duration) 范围内
"""
if split_time <= 0 or split_time >= duration:
raise ValueError(f"分割时间必须在 (0, {duration:.3f}) 范围内,当前: {split_time}")
def calculate_split(
duration: float,
split_time: float,
start_time: float = 0.0,
*,
precision: int = ROUND_PRECISION,
) -> SplitResult:
"""计算片段分割后的各项参数。
左半部分:从 0 到 split_time
右半部分:从 split_time 到 duration
Args:
duration: 原片段时长(秒)
split_time: 分割点(秒)
start_time: 原片段起始时间(秒),右半部分 start_time 需要加上 left_duration
precision: 小数精度(默认 3 位,即毫秒)
Returns:
SplitResult 包含左右部分的时长、右半部分 start_time、trim 信息
"""
validate_split_time(split_time, duration)
left_duration = round(split_time, precision)
right_duration = round(duration - split_time, precision)
right_start_time = round(start_time + left_duration, precision)
return SplitResult(
left_duration=left_duration,
right_duration=right_duration,
right_start_time=right_start_time,
left_trim_end=right_duration,
right_trim_start=left_duration,
)
# ── 合并 ──────────────────────────────────────────────────────────────────────
def validate_merge_clips(clips: list[Any]) -> tuple[str, int]:
"""校验待合并的片段列表。
校验项:
1. 至少 2 个片段
2. 属于同一计划
3. order 连续
4. 类型一致
Args:
clips: 按任意顺序排列的片段列表(会自动按 order 排序)
Returns:
(plan_id, first_order) 元组
Raises:
ValueError: 校验失败
"""
if len(clips) < 2:
raise ValueError("至少需要 2 个片段才能合并")
# 校验:同一计划
plan_id = clips[0].plan_id
for c in clips[1:]:
if c.plan_id != plan_id:
raise ValueError("只能合并同一计划下的片段")
# 按 order 排序
sorted_clips = sorted(clips, key=lambda c: c.order)
# 校验:order 连续
for i in range(1, len(sorted_clips)):
if sorted_clips[i].order != sorted_clips[i - 1].order + 1:
raise ValueError(f"片段不连续:order {sorted_clips[i-1].order}{sorted_clips[i].order}")
# 校验:类型一致
clip_type = sorted_clips[0].clip_type
for c in sorted_clips[1:]:
if c.clip_type != clip_type:
raise ValueError("只能合并相同类型的片段")
return plan_id, sorted_clips[0].order
def calculate_merge(
clips: list[Any],
*,
precision: int = ROUND_PRECISION,
) -> MergeResult:
"""计算多个片段合并后的参数。
合并规则:
- 时长:所有片段时长之和
- 文案:用换行连接非空文案
- config:后面的覆盖前面的,移除 trim_start/trim_end
- first_order:第一个片段的 order
- shift_amount:合并后 order 前移位数(n-1)
Args:
clips: 待合并片段列表(会自动按 order 排序)
precision: 时长精度(默认 3 位)
Returns:
MergeResult 合并结果
"""
if not clips:
raise ValueError("合并的片段列表不能为空")
# 按 order 排序
sorted_clips = sorted(clips, key=lambda c: c.order)
# 总时长
total_duration = round(sum(c.duration for c in sorted_clips), precision)
# 合并文案
merged_text = "\n".join(c.text_content for c in sorted_clips if c.text_content and c.text_content.strip())
# 合并 config(后面的覆盖前面的)
merged_config: dict[str, Any] = {}
for c in sorted_clips:
if c.config:
merged_config.update(c.config)
# 清理 trim 相关字段(合并后就是完整片段了)
merged_config.pop("trim_start", None)
merged_config.pop("trim_end", None)
first_order = sorted_clips[0].order
shift_amount = len(sorted_clips) - 1
return MergeResult(
total_duration=total_duration,
merged_text=merged_text,
merged_config=merged_config,
first_order=first_order,
shift_amount=shift_amount,
)
# ── Order 重排 ───────────────────────────────────────────────────────────────
def calculate_reorder_new_orders(
ordered_ids: list[str],
current_items: list[Any],
*,
id_attr: str = "id",
order_attr: str = "order",
) -> dict[str, int]:
"""根据新顺序计算每个 item 的新 order 值。
Args:
ordered_ids: 按新顺序排列的 ID 列表
current_items: 当前所有 item 列表
id_attr: ID 属性名
order_attr: order 属性名
Returns:
{item_id: new_order} 映射
Raises:
ValueError: ID 列表与当前 items 不匹配
"""
current_ids = {getattr(c, id_attr) for c in current_items}
ordered_id_set = set(ordered_ids)
if ordered_id_set != current_ids:
raise ValueError("ID 列表与当前 items 不匹配")
return {item_id: idx for idx, item_id in enumerate(ordered_ids)}
def calculate_shift_orders(
items: list[Any],
threshold_order: int,
shift: int,
*,
excluded_ids: set[str] | None = None,
order_attr: str = "order",
id_attr: str = "id",
) -> list[tuple[Any, int]]:
"""计算 order 需要偏移的 items 及新 order 值。
Args:
items: 所有 item 列表
threshold_order: 只处理 order > threshold_order 的 item
shift: 偏移量(正数加,负数减)
excluded_ids: 排除的 ID 集合
order_attr: order 属性名
id_attr: ID 属性名
Returns:
[(item, new_order), ...] 列表
"""
excluded = excluded_ids or set()
result: list[tuple[Any, int]] = []
for item in items:
item_id = getattr(item, id_attr)
if item_id in excluded:
continue
current_order = getattr(item, order_attr)
if current_order > threshold_order:
result.append((item, current_order + shift))
return result
+268
View File
@@ -0,0 +1,268 @@
"""色彩调色配置领域模型 — 纯逻辑,无 FFmpeg 依赖.
抽离自 color_grade_engine.py 的数据类、预设常量和纯逻辑函数,
方便单测覆盖,同时保持向后兼容。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# ── 预设常量 ──────────────────────────────────────────────────────────────────
PRESET_FRESH = "fresh" # 清新
PRESET_JAPANESE = "japanese" # 日系
PRESET_VINTAGE = "vintage" # 复古
PRESET_CINEMA = "cinema" # 电影
PRESET_FILM = "film" # 胶片
PRESET_BW = "black_white" # 黑白
PRESET_WARM = "warm" # 暖色
PRESET_COOL = "cool" # 冷色
VALID_PRESETS = {
PRESET_FRESH,
PRESET_JAPANESE,
PRESET_VINTAGE,
PRESET_CINEMA,
PRESET_FILM,
PRESET_BW,
PRESET_WARM,
PRESET_COOL,
}
# 预设名称 → 中文显示名
PRESET_DISPLAY_NAMES = {
PRESET_FRESH: "清新",
PRESET_JAPANESE: "日系",
PRESET_VINTAGE: "复古",
PRESET_CINEMA: "电影",
PRESET_FILM: "胶片",
PRESET_BW: "黑白",
PRESET_WARM: "暖色",
PRESET_COOL: "冷色",
}
# 预设参数配置
# 每个预设包含:brightness, contrast, saturation, temperature, hue
PRESET_PARAMS: dict[str, dict[str, float]] = {
PRESET_FRESH: {
"brightness": 8,
"contrast": 10,
"saturation": 120,
"temperature": -8,
"hue": 5,
},
PRESET_JAPANESE: {
"brightness": 12,
"contrast": -15,
"saturation": 70,
"temperature": 10,
"hue": -5,
},
PRESET_VINTAGE: {
"brightness": -5,
"contrast": 5,
"saturation": 60,
"temperature": 25,
"hue": -8,
},
PRESET_CINEMA: {
"brightness": -8,
"contrast": 20,
"saturation": 75,
"temperature": -15,
"hue": -3,
},
PRESET_FILM: {
"brightness": -3,
"contrast": 12,
"saturation": 95,
"temperature": 15,
"hue": -2,
},
PRESET_BW: {
"brightness": 0,
"contrast": 15,
"saturation": 0,
"temperature": 0,
"hue": 0,
},
PRESET_WARM: {
"brightness": 5,
"contrast": 8,
"saturation": 110,
"temperature": 30,
"hue": -5,
},
PRESET_COOL: {
"brightness": 3,
"contrast": 8,
"saturation": 105,
"temperature": -25,
"hue": 8,
},
}
# ── 参数范围 ──────────────────────────────────────────────────────────────────
PARAM_RANGES: dict[str, tuple[float, float]] = {
"brightness": (-100.0, 100.0),
"contrast": (-100.0, 100.0),
"saturation": (0.0, 200.0),
"temperature": (-100.0, 100.0),
"hue": (-180.0, 180.0),
}
# 默认值(零调整)
DEFAULT_PARAMS: dict[str, float] = {
"brightness": 0.0,
"contrast": 0.0,
"saturation": 100.0,
"temperature": 0.0,
"hue": 0.0,
}
ALL_PARAM_KEYS = ("brightness", "contrast", "saturation", "temperature", "hue")
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ColorGradeConfig:
"""色彩调色配置.
优先级:自定义参数 > 预设参数
即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖
"""
enabled: bool = False
preset: str = "" # 预设名称,空表示不使用预设
# 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值)
brightness: float | None = None
contrast: float | None = None
saturation: float | None = None
temperature: float | None = None
hue: float | None = None
def resolve_params(self) -> dict[str, float]:
"""解析最终调色参数(预设 + 自定义覆盖 + 边界钳制).
Returns:
包含 brightness, contrast, saturation, temperature, hue 的参数字典
"""
# 1. 从默认值开始
params = dict(DEFAULT_PARAMS)
# 2. 应用预设
if self.preset and self.preset in PRESET_PARAMS:
params.update(PRESET_PARAMS[self.preset])
# 3. 应用自定义覆盖
if self.brightness is not None:
params["brightness"] = self.brightness
if self.contrast is not None:
params["contrast"] = self.contrast
if self.saturation is not None:
params["saturation"] = self.saturation
if self.temperature is not None:
params["temperature"] = self.temperature
if self.hue is not None:
params["hue"] = self.hue
# 4. 边界钳制
for key, (min_val, max_val) in PARAM_RANGES.items():
params[key] = max(min_val, min(max_val, params[key]))
return params
def has_effect(self) -> bool:
"""判断是否有实际调色效果(所有参数都是默认值则无效果).
用于优化:无效果时跳过滤镜,不浪费性能。
"""
params = self.resolve_params()
for key, default in DEFAULT_PARAMS.items():
if abs(params[key] - default) > 0.001:
return True
return False
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
preset = data.get("preset", "")
if preset and preset not in VALID_PRESETS:
logger.warning("未知的调色预设: %s,忽略预设", preset)
preset = ""
def _get_float(key: str) -> float | None:
val = data.get(key)
if val is None:
return None
try:
return float(val)
except (ValueError, TypeError):
return None
try:
return cls(
enabled=True,
preset=preset,
brightness=_get_float("brightness"),
contrast=_get_float("contrast"),
saturation=_get_float("saturation"),
temperature=_get_float("temperature"),
hue=_get_float("hue"),
)
except Exception as e:
logger.warning("调色配置解析失败: %s,使用默认配置", e)
return cls(enabled=False)
def validate(self) -> tuple[bool, str]:
"""校验配置合法性,返回 (是否合法, 错误信息)."""
if not self.enabled:
return True, ""
if self.preset and self.preset not in VALID_PRESETS:
return False, f"未知的预设: {self.preset}"
# 解析后的参数自然在合法范围内(resolve_params 会钳制)
# 这里检查是否有明显无效的自定义值
for key in ALL_PARAM_KEYS:
val = getattr(self, key)
if val is not None:
min_val, max_val = PARAM_RANGES[key]
if val < min_val or val > max_val:
return False, f"{key}超出范围[{min_val}, {max_val}]: {val}"
return True, ""
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def get_preset_names() -> list[tuple[str, str]]:
"""获取所有预设的 (name, display_name) 列表."""
return [(p, PRESET_DISPLAY_NAMES[p]) for p in sorted(VALID_PRESETS)]
def get_preset_params(preset: str) -> dict[str, float] | None:
"""获取指定预设的参数,不存在返回 None."""
return PRESET_PARAMS.get(preset)
def clamp_param(param_name: str, value: float) -> float:
"""将参数钳制到合法范围内."""
if param_name not in PARAM_RANGES:
return value
min_val, max_val = PARAM_RANGES[param_name]
return max(min_val, min(max_val, value))
+219
View File
@@ -0,0 +1,219 @@
"""片头片尾配置领域模型 — 纯逻辑,无外部依赖.
抽离自 intro_outro_engine.py 的数据类和纯逻辑函数,
方便单测覆盖,同时保持向后兼容。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# ── 常量 ──────────────────────────────────────────────────────────────────────
INTRO_OUTRO_TYPE_NONE = "none"
INTRO_OUTRO_TYPE_VIDEO = "video"
INTRO_OUTRO_TYPE_TEXT = "text"
INTRO_OUTRO_TYPE_FOLLOW = "follow"
TRANSITION_FADE = "fade"
TRANSITION_SLIDE = "slide"
TRANSITION_WIPE = "wipe"
_VALID_INTRO_TYPES = {INTRO_OUTRO_TYPE_NONE, INTRO_OUTRO_TYPE_VIDEO, INTRO_OUTRO_TYPE_TEXT}
_VALID_OUTRO_TYPES = {
INTRO_OUTRO_TYPE_NONE,
INTRO_OUTRO_TYPE_VIDEO,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_FOLLOW,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class IntroOutroConfig:
"""片头片尾配置.
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
"""
enabled: bool = False
# 片头
intro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text
intro_video_path: str = "" # 视频片段路径
intro_duration: float = 3.0 # 片头时长(秒)
# 文字片头配置
intro_background: str = "#000000" # 背景色
intro_title: str = ""
intro_subtitle: str = ""
intro_title_color: str = "white"
intro_title_size: int = 48
intro_subtitle_color: str = "gray"
intro_subtitle_size: int = 24
# 片尾
outro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text | follow
outro_video_path: str = "" # 视频片段路径
outro_duration: float = 3.0 # 片尾时长(秒)
# 文字片尾配置
outro_background: str = "#000000"
outro_title: str = "感谢观看"
outro_subtitle: str = "点赞关注不迷路"
outro_title_color: str = "white"
outro_title_size: int = 48
outro_subtitle_color: str = "gray"
outro_subtitle_size: int = 24
# 转场
transition_effect: str = TRANSITION_FADE
transition_duration: float = 0.5
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "IntroOutroConfig":
"""从字典构造."""
if not data:
return cls()
enabled = data.get("enabled", False)
if not enabled:
return cls()
intro = data.get("intro", {}) or {}
outro = data.get("outro", {}) or {}
# 安全解析数值,失败时回退到默认值
try:
intro_duration = float(intro.get("duration", 3.0))
except (TypeError, ValueError):
intro_duration = 3.0
try:
intro_title_size = int(intro.get("title_size", 48))
except (TypeError, ValueError):
intro_title_size = 48
try:
intro_subtitle_size = int(intro.get("subtitle_size", 24))
except (TypeError, ValueError):
intro_subtitle_size = 24
try:
outro_duration = float(outro.get("duration", 3.0))
except (TypeError, ValueError):
outro_duration = 3.0
try:
outro_title_size = int(outro.get("title_size", 48))
except (TypeError, ValueError):
outro_title_size = 48
try:
outro_subtitle_size = int(outro.get("subtitle_size", 24))
except (TypeError, ValueError):
outro_subtitle_size = 24
try:
transition_duration = float(data.get("transition_duration", 0.5))
except (TypeError, ValueError):
transition_duration = 0.5
return cls(
enabled=True,
# 片头
intro_type=str(intro.get("type", INTRO_OUTRO_TYPE_NONE)),
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
intro_duration=intro_duration,
intro_background=str(intro.get("background", "#000000")),
intro_title=str(intro.get("title", "") or ""),
intro_subtitle=str(intro.get("subtitle", "") or ""),
intro_title_color=str(intro.get("title_color", "white")),
intro_title_size=intro_title_size,
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
intro_subtitle_size=intro_subtitle_size,
# 片尾
outro_type=str(outro.get("type", INTRO_OUTRO_TYPE_NONE)),
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
outro_duration=outro_duration,
outro_background=str(outro.get("background", "#000000")),
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
outro_title_color=str(outro.get("title_color", "white")),
outro_title_size=outro_title_size,
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
outro_subtitle_size=outro_subtitle_size,
# 转场
transition_effect=str(data.get("transition", TRANSITION_FADE)),
transition_duration=transition_duration,
)
@property
def has_intro(self) -> bool:
"""是否有片头(视频或文字类型)."""
return self.enabled and self.intro_type in (
INTRO_OUTRO_TYPE_VIDEO,
INTRO_OUTRO_TYPE_TEXT,
)
@property
def has_outro(self) -> bool:
"""是否有片尾(视频/文字/follow类型)."""
return self.enabled and self.outro_type in (
INTRO_OUTRO_TYPE_VIDEO,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_FOLLOW,
)
@property
def total_extra_duration(self) -> float:
"""片头片尾总共增加的时长(秒)."""
total = 0.0
if self.has_intro and self.intro_duration > 0:
total += self.intro_duration
if self.has_outro and self.outro_duration > 0:
total += self.outro_duration
return total
def validate(self) -> tuple[bool, str]:
"""校验配置合法性,返回 (是否合法, 错误信息)."""
if not self.enabled:
return True, ""
if self.intro_type not in _VALID_INTRO_TYPES:
return False, f"无效的片头类型: {self.intro_type}"
if self.outro_type not in _VALID_OUTRO_TYPES:
return False, f"无效的片尾类型: {self.outro_type}"
if self.intro_type == INTRO_OUTRO_TYPE_VIDEO and not self.intro_video_path:
return False, "视频片头缺少 video_path"
if self.intro_type == INTRO_OUTRO_TYPE_TEXT and not self.intro_title:
return False, "文字片头缺少 title"
if self.outro_type == INTRO_OUTRO_TYPE_VIDEO and not self.outro_video_path:
return False, "视频片尾缺少 video_path"
if self.outro_type in (INTRO_OUTRO_TYPE_TEXT, INTRO_OUTRO_TYPE_FOLLOW) and not self.outro_title:
return False, "文字片尾缺少 title"
if self.intro_duration <= 0:
return False, "片头时长必须大于 0"
if self.outro_duration <= 0:
return False, "片尾时长必须大于 0"
if self.transition_duration < 0:
return False, "转场时长不能为负数"
if self.intro_title_size <= 0:
return False, "片头标题字号必须大于 0"
if self.intro_subtitle_size <= 0:
return False, "片头副标题字号必须大于 0"
if self.outro_title_size <= 0:
return False, "片尾标题字号必须大于 0"
if self.outro_subtitle_size <= 0:
return False, "片尾副标题字号必须大于 0"
return True, ""
+110
View File
@@ -0,0 +1,110 @@
"""媒体文件有效性校验与元数据解析工具。
从 worker ingest 任务中抽取的纯逻辑模块,包含:
- FPS 解析:从分数格式字符串(如 30000/1001)安全解析帧率
- 媒体有效性校验:根据元数据判断视频/音频/图片文件是否有效
- 常量定义:最小文件大小、支持的视频编码白名单
"""
from __future__ import annotations
# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体
MIN_VIDEO_FILE_SIZE = 1024 # 1KB
MIN_AUDIO_FILE_SIZE = 100 # 100B
MIN_IMAGE_FILE_SIZE = 100 # 100B
# 支持的视频编码格式(白名单,尽可能放宽)
# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested
SUPPORTED_VIDEO_CODECS: frozenset[str] = frozenset(
{
"h264",
"avc1",
"avc", # H.264 / AVC
"hevc",
"h265",
"hev1",
"hvc1", # H.265 / HEVC
"vp9",
"vp09", # VP9
"av1",
"av01", # AV1
"vp8",
"vp08", # VP8
"mpeg4",
"mp4v", # MPEG-4
"mpeg2video",
"mpg2", # MPEG-2
"wmv2",
"wmv1",
"vc1", # WMV / VC-1
"flv1",
"flv",
"vp6f", # Flash / FLV
"theora",
"ogg", # Theora
"prores",
"prores_ks",
"apcn",
"apch",
"apco",
"apcs",
"ap4h",
"ap4x", # Apple ProRes
"dnxhd",
"dnxhr", # DNxHD / DNxHR
}
)
def safe_parse_fps(fps_str: str) -> float:
"""Safely parse fps from a fraction string like "30/1" or "30000/1001".
Args:
fps_str: FPS 字符串,支持小数格式("30.0")或分数格式("30000/1001"
Returns:
解析得到的帧率浮点数;解析失败或分母为0时返回 0.0
"""
try:
if "/" in fps_str:
num, den = fps_str.split("/", 1)
den_val = float(den)
if den_val == 0:
return 0.0
return float(num) / den_val
return float(fps_str)
except (ValueError, ZeroDivisionError):
return 0.0
def is_valid_media(metadata: dict, media_type: str) -> bool:
"""根据元数据判断文件是否为有效媒体文件。
Args:
metadata: 媒体元数据字典,可能包含 size_bytes / duration / codec / width / height 等
media_type: 媒体类型(video / audio / image
Returns:
True 表示文件有效
"""
size = int(metadata.get("size_bytes", 0))
if media_type == "video":
duration = float(metadata.get("duration", 0))
if size < MIN_VIDEO_FILE_SIZE or duration <= 0:
return False
# 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许
# 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截
codec = str(metadata.get("codec", "")).lower()
if codec and codec not in SUPPORTED_VIDEO_CODECS:
# 非白名单编码仍允许通过,仅记录日志(调用方负责日志)
pass
return True
if media_type == "audio":
duration = float(metadata.get("duration", 0))
return size >= MIN_AUDIO_FILE_SIZE and duration > 0
if media_type == "image":
width = int(metadata.get("width", 0))
height = int(metadata.get("height", 0))
return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0
return False
+231
View File
@@ -0,0 +1,231 @@
"""音频降噪配置领域模型 — 纯逻辑,无FFmpeg依赖.
抽离自 noise_reduction_engine.py,包含:
- NoiseReductionLevel 枚举(low/medium/high/custom
- NoiseReductionConfig 数据类(解析/钳制/效果判断)
- 等级预设参数
- afftdn / arnndn 滤镜构建
- 便捷函数(apply_noise_reduction_if_needed
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Any
logger = logging.getLogger(__name__)
# ── 降噪等级 ──────────────────────────────────────────────────────────────────
class NoiseReductionLevel(str, Enum):
"""降噪等级预设."""
LOW = "low" # 轻度降噪,保留细节,适合轻微背景噪音
MEDIUM = "medium" # 中度降噪,平衡效果和音质
HIGH = "high" # 高度降噪,适合嘈杂环境,可能轻微影响音质
CUSTOM = "custom" # 自定义参数
# 各等级对应的降噪参数(afftdn 的 noise floor,单位 dB
# 值越大(越接近 0),降噪越强;值越小(越负),降噪越弱
_LEVEL_PARAMS: dict[NoiseReductionLevel, dict[str, float]] = {
NoiseReductionLevel.LOW: {
"nf": -35, # 噪音阈值(dB),越负越保守
"tn": -10, # 噪音频谱平滑度
"tr": 50, # 时间分辨率(ms
},
NoiseReductionLevel.MEDIUM: {
"nf": -25,
"tn": -10,
"tr": 50,
},
NoiseReductionLevel.HIGH: {
"nf": -15,
"tn": -5,
"tr": 30,
},
}
# 参数范围
MIN_NOISE_FLOOR = -60.0
MAX_NOISE_FLOOR = -5.0
# 默认值
DEFAULT_LEVEL = NoiseReductionLevel.MEDIUM
DEFAULT_NOISE_FLOOR = -25.0
# ── 配置模型 ──────────────────────────────────────────────────────────────────
@dataclass
class NoiseReductionConfig:
"""音频降噪配置.
Attributes:
enabled: 是否启用降噪
level: 降噪等级 low/medium/high/custom
noise_floor: 自定义噪音阈值(dB),仅 level=custom 时有效,范围 -60 ~ -5
voice_enhance: 是否启用人声增强
"""
enabled: bool = False
level: NoiseReductionLevel = DEFAULT_LEVEL
noise_floor: float = DEFAULT_NOISE_FLOOR # dB
voice_enhance: bool = False
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> NoiseReductionConfig:
"""从字典解析配置,参数越界自动钳制."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
level_str = str(data.get("level", "medium")).lower()
try:
level = NoiseReductionLevel(level_str)
except ValueError:
level = DEFAULT_LEVEL
try:
noise_floor = float(data.get("noise_floor", DEFAULT_NOISE_FLOOR))
except (TypeError, ValueError):
noise_floor = DEFAULT_NOISE_FLOOR
voice_enhance = bool(data.get("voice_enhance", False))
# 钳制到合法范围
noise_floor = max(MIN_NOISE_FLOOR, min(MAX_NOISE_FLOOR, noise_floor))
return cls(
enabled=True,
level=level,
noise_floor=noise_floor,
voice_enhance=voice_enhance,
)
def has_effect(self) -> bool:
"""判断是否有实际降噪效果."""
return self.enabled
def get_effective_noise_floor(self) -> float:
"""获取实际生效的噪音阈值(dB."""
if self.level == NoiseReductionLevel.CUSTOM:
return self.noise_floor
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[DEFAULT_LEVEL])
return float(params["nf"])
def get_level_params(self) -> dict[str, float]:
"""获取当前等级的完整参数字典."""
if self.level == NoiseReductionLevel.CUSTOM:
return {
"nf": self.noise_floor,
"tn": -10.0,
"tr": 50.0,
}
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[DEFAULT_LEVEL])
return {k: float(v) for k, v in params.items()}
def validate(self) -> tuple[bool, str]:
"""校验配置是否有效."""
if not self.enabled:
return True, ""
if not (MIN_NOISE_FLOOR <= self.noise_floor <= MAX_NOISE_FLOOR):
return False, f"noise_floor 必须在 {MIN_NOISE_FLOOR}~{MAX_NOISE_FLOOR} dB 之间"
return True, ""
# ── 滤镜构建 ────────────────────────────────────────────────────────────────
def build_afftdn_filter(
config: NoiseReductionConfig,
input_label: str,
output_label: str,
) -> str:
"""构建 afftdn 音频降噪滤镜字符串.
Args:
config: 降噪配置
input_label: 输入标签,如 "[0:a]""[a0]"
output_label: 输出标签,如 "[nr0]"
Returns:
FFmpeg 滤镜字符串
"""
if not config.has_effect():
return f"{input_label}anull{output_label}"
params = config.get_level_params()
nf = params["nf"]
tn = params["tn"]
tr = params["tr"]
# 构建 afftdn 滤镜
filter_parts = [f"afftdn=nf={nf}:tn={tn}:tr={tr}"]
# 人声增强:通过 highpass + 压缩 + 响度归一化实现
if config.voice_enhance:
filter_parts.append("highpass=f=80")
filter_parts.append("acompressor=threshold=-20:ratio=2:attack=5:release=50")
filter_parts.append("loudnorm=I=-16:TP=-1.5:LRA=11")
return f"{input_label}{','.join(filter_parts)}{output_label}"
def build_arnndn_filter(
config: NoiseReductionConfig,
input_label: str,
output_label: str,
model_file: str,
) -> str:
"""使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件).
注意:需要额外下载 RNNNoise 模型文件,默认使用 afftdn(无需额外依赖)。
"""
if not config.has_effect():
return f"{input_label}anull{output_label}"
return f"{input_label}arnndn=m={model_file}{output_label}"
# ── 便捷函数 ────────────────────────────────────────────────────────────────
def apply_noise_reduction_if_needed(
config_data: dict[str, Any] | None,
input_label: str,
output_label: str,
) -> str | None:
"""便捷函数:根据配置判断是否需要应用音频降噪.
Args:
config_data: 降噪配置字典
input_label: 输入标签
output_label: 输出标签
Returns:
滤镜字符串,不需要降噪时返回 None
"""
if not config_data:
return None
try:
config = NoiseReductionConfig.from_dict(config_data)
if not config.has_effect():
return None
return build_afftdn_filter(config, input_label, output_label)
except Exception as e:
logger.warning("[noise-reduction] 应用降噪失败,跳过: %s", e)
return None
def get_level_names() -> list[str]:
"""获取所有降噪等级名称列表."""
return [level.value for level in NoiseReductionLevel]
+265
View File
@@ -0,0 +1,265 @@
"""画中画(PiP)配置领域模型 — 纯逻辑,无外部依赖.
抽离自 pip_engine.py 的数据类和纯逻辑函数,
方便单测覆盖,同时保持向后兼容。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
# ── 位置常量 ──────────────────────────────────────────────────────────────────
POSITION_TOP_LEFT = "top_left"
POSITION_TOP_CENTER = "top_center"
POSITION_TOP_RIGHT = "top_right"
POSITION_CENTER_LEFT = "center_left"
POSITION_CENTER = "center"
POSITION_CENTER_RIGHT = "center_right"
POSITION_BOTTOM_LEFT = "bottom_left"
POSITION_BOTTOM_CENTER = "bottom_center"
POSITION_BOTTOM_RIGHT = "bottom_right"
_VALID_POSITIONS = {
POSITION_TOP_LEFT,
POSITION_TOP_CENTER,
POSITION_TOP_RIGHT,
POSITION_CENTER_LEFT,
POSITION_CENTER,
POSITION_CENTER_RIGHT,
POSITION_BOTTOM_LEFT,
POSITION_BOTTOM_CENTER,
POSITION_BOTTOM_RIGHT,
}
# 动画类型
ANIMATION_FADE = "fade"
ANIMATION_SLIDE_LEFT = "slide_left"
ANIMATION_SLIDE_RIGHT = "slide_right"
ANIMATION_SLIDE_TOP = "slide_top"
ANIMATION_SLIDE_BOTTOM = "slide_bottom"
ANIMATION_SCALE = "scale"
_VALID_ANIMATIONS = {
ANIMATION_FADE,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SCALE,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class PiPLayerConfig:
"""单个画中画图层配置."""
# 素材来源
source: str = ""
source_type: str = "asset_id" # "asset_id" | "url" | "local_path"
# 位置配置
position: str = POSITION_BOTTOM_RIGHT
x: int | str = 0
y: int | str = 0
margin: int = 20
# 大小配置
width: int | str = "25%"
height: int | str = "" # 空则按比例自适应
# 样式
opacity: float = 1.0
corner_radius: int = 0
border_width: int = 0
border_color: str = "white"
# 时间控制
start_time: float = 0.0
duration: float = 0.0 # 0表示全程显示
# 动画
animation_in: str = ""
animation_out: str = ""
animation_duration: float = 0.5
# 层级
z_index: int = 1
def validate(self) -> tuple[bool, str]:
"""校验配置合法性,返回 (是否合法, 错误信息)."""
if not self.source:
return False, "source不能为空"
if self.position != "custom" and self.position not in _VALID_POSITIONS:
return False, f"无效的position: {self.position}"
if self.opacity < 0 or self.opacity > 1:
return False, "opacity必须在0-1之间"
if self.corner_radius < 0:
return False, "corner_radius不能为负数"
if self.start_time < 0:
return False, "start_time不能为负数"
if self.duration < 0:
return False, "duration不能为负数"
if self.animation_in and self.animation_in not in _VALID_ANIMATIONS:
return False, f"无效的入场动画: {self.animation_in}"
if self.animation_out and self.animation_out not in _VALID_ANIMATIONS:
return False, f"无效的出场动画: {self.animation_out}"
if self.animation_duration < 0:
return False, "animation_duration不能为负数"
return True, ""
@dataclass
class PiPConfig:
"""画中画整体配置."""
enabled: bool = False
layers: list[PiPLayerConfig] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
layers_data = data.get("layers", [])
layers: list[PiPLayerConfig] = []
for layer_data in layers_data:
try:
layer = PiPLayerConfig(
source=layer_data.get("source", ""),
source_type=layer_data.get("source_type", "asset_id"),
position=layer_data.get("position", POSITION_BOTTOM_RIGHT),
x=layer_data.get("x", 0),
y=layer_data.get("y", 0),
margin=int(layer_data.get("margin", 20)),
width=layer_data.get("width", "25%"),
height=layer_data.get("height", ""),
opacity=float(layer_data.get("opacity", 1.0)),
corner_radius=int(layer_data.get("corner_radius", 0)),
border_width=int(layer_data.get("border_width", 0)),
border_color=layer_data.get("border_color", "white"),
start_time=float(layer_data.get("start_time", 0.0)),
duration=float(layer_data.get("duration", 0.0)),
animation_in=layer_data.get("animation_in", ""),
animation_out=layer_data.get("animation_out", ""),
animation_duration=float(layer_data.get("animation_duration", 0.5)),
z_index=int(layer_data.get("z_index", 1)),
)
valid, err = layer.validate()
if valid:
layers.append(layer)
else:
logger.warning("PiP图层配置无效,跳过: %s", err)
except (ValueError, TypeError) as e:
logger.warning("PiP图层解析失败,跳过: %s", e)
# 按 z_index 排序
layers.sort(key=lambda layer: layer.z_index)
return cls(enabled=bool(layers), layers=layers)
@property
def layer_count(self) -> int:
"""有效图层数量."""
return len(self.layers)
@property
def max_z_index(self) -> int:
"""最大 z_index."""
if not self.layers:
return 0
return max(l.z_index for l in self.layers)
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
def parse_size_value(value: int | str, base: int, default_pct: float = 0.25) -> int:
"""解析尺寸值(像素或百分比).
Args:
value: 尺寸值,int(像素)或 str(如 "30%"
base: 基准尺寸(用于百分比计算)
default_pct: 解析失败时的默认百分比
Returns:
像素尺寸,>= 1
"""
if isinstance(value, int):
return max(1, value)
if isinstance(value, str) and value.endswith("%"):
try:
pct = float(value.rstrip("%")) / 100.0
return max(1, int(base * pct))
except (ValueError, TypeError):
return max(1, int(base * default_pct))
try:
return max(1, int(value))
except (ValueError, TypeError):
return max(1, int(base * default_pct))
def calculate_pip_position(
position: str,
output_width: int,
output_height: int,
pip_width: int,
pip_height: int,
margin: int = 20,
custom_x: int | str = 0,
custom_y: int | str = 0,
) -> tuple[int, int]:
"""计算画中画的实际像素位置 (x, y).
Args:
position: 9宫格位置或 "custom"
output_width: 画布宽度
output_height: 画布高度
pip_width: 画中画宽度
pip_height: 画中画高度
margin: 9宫格边距
custom_x: 自定义xposition=custom时有效)
custom_y: 自定义yposition=custom时有效)
Returns:
(x, y) 像素坐标
"""
W = output_width
H = output_height
m = margin
if position == "custom":
x = parse_size_value(custom_x, W)
y = parse_size_value(custom_y, H)
return (x, y)
pos_map = {
POSITION_TOP_LEFT: (m, m),
POSITION_TOP_CENTER: ((W - pip_width) // 2, m),
POSITION_TOP_RIGHT: (W - pip_width - m, m),
POSITION_CENTER_LEFT: (m, (H - pip_height) // 2),
POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2),
POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2),
POSITION_BOTTOM_LEFT: (m, H - pip_height - m),
POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m),
POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m),
}
return pos_map.get(position, pos_map[POSITION_BOTTOM_RIGHT])
+1 -5
View File
@@ -321,11 +321,7 @@ def create_clips_from_configs(
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串 # transition_effect 可能是枚举或字符串
transition = ( transition = cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
)
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名) # 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
clip_cfg = cfg.config or {} clip_cfg = cfg.config or {}

Some files were not shown because too many files have changed in this diff Show More