Compare commits

...

66 Commits

Author SHA1 Message Date
xiaoxia 17c2cceaa8 Merge branch 'develop' into refactor/subscription-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m1s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 55s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 14s
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 1m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Successful in 2m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m33s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m41s
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 / 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 18s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
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 / Deploy Production (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 16s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:34:52 +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 3ff041440b test(wave93): 43 unit tests for duplication domain models (#956)
CI/CD Pipeline / Build Production API Image (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 2m37s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m20s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m14s
CI/CD Pipeline / Frontend Lint (push) Failing after 47s
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 19m58s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m46s
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 Unit Tests (push) Successful in 1m15s
CI/CD Pipeline / Integration Tests (push) Successful in 2m34s
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m4s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m55s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 43m35s
2026-07-26 18:17:04 +08:00
xiaoxia b896873ece test(wave92): extract asset_scoring domain module + 89 unit tests (#955)
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 18:15:59 +08:00
xiaoxia 4df4a937e4 test(wave91): add 59 unit tests for generation_plan_builder (#945)
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 18:15:48 +08:00
xiaoxia 673d18aa83 test(wave90): add 52 unit tests for plan_generator_utils (#940)
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 18:15:38 +08:00
xiaoxia 3b949a464f 第89波 - 拆分asset_analyzer评分纯逻辑 + 64单测 (#938)
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 18:15:22 +08:00
xiaoxia 0661b443a1 refactor(api): 拆分 subscription.ts 为目录结构(types/subscription/index)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 30s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 57s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 48s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m35s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m12s
AI Code Review / AI Code Review (pull_request) Successful in 1m38s
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 / Frontend Unit Tests (pull_request) Failing after 30s
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 / 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
将 106 行的 subscription.ts 拆分为目录化结构:
- types.ts: 类型定义(Plan/SubscriptionInfo/BillingRecord 等)
- subscription.ts: 全部 5 个 API 函数
- index.ts: 统一入口 re-export,保持 @/api/subscription 路径向后兼容
2026-07-26 17:15:38 +08:00
xiaoxia daa0e1f7b5 fix(lint): 修复EditingPlanner Phase2引入的TS类型错误 (#936)
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 17:07:30 +08:00
CI Bot 5fa915cdad 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 08:29:38 +00:00
xiaoxia 1242b62165 test(wave87): worker层speed_engine+reverse_engine纯逻辑单测 (+85) (#930)
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 16:22:38 +08:00
CI Bot 4251970b49 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 07:42:26 +00:00
xiaoxia 64387c00bb refactor(editing-planner): Phase 2 - 重构9个面板组件 (#928)
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-26 15:38:18 +08:00
xiaoxia eae6dcac4f test(wave86): worker层VoiceExtractor/CoverGenerator/VideoMixer单测 (#901) 2026-07-26 15:38:18 +08:00
xiaoxia dab2a4fdb2 test(wave75): domain utils补充单测 +54 (#883) 2026-07-26 15:38:17 +08:00
xiaoxia 6a303a3b6e fix(ci): #916 improve check-frontend-only: pagination + infra detection + fix empty-line bug (#926)
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 15:19:30 +08:00
xiaoxia 23573a8209 fix(ci): #909 #910 #911 fix monitor workflows - checkout + runner labels (#927)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m3s
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 - Migration (alembic) (push) Successful in 53s
CI/CD Pipeline / Frontend Lint (push) Successful in 29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 51s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m34s
CI/CD Pipeline / Integration Tests (push) Successful in 2m20s
CI/CD Pipeline / Unit Tests (push) Successful in 6m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m46s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m52s
2026-07-26 14:10:34 +08:00
xiaoxia 38e40d727b fix(ci): #912 replace hardcoded staging credentials with secrets (#925)
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): #912 replace hardcoded staging credentials with secrets
2026-07-26 14:09:55 +08:00
xiaoxia dcfddc6695 fix(ci): #908 remove invalid --pr-days param from acr-cleanup (#923)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 47s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
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) Successful in 3m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m58s
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 API Image (push) Successful in 11m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 51s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m55s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Successful in 4m50s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m23s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m44s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 13:15:09 +08:00
xiaoxia e43737658a fix(ci): #905 #914 code quality full scan unification + 2 ruff fixes (#922)
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 13:14:47 +08:00
204 changed files with 30843 additions and 11217 deletions
+5 -6
View File
@@ -36,12 +36,11 @@ jobs:
GITEA_REPO: xiaoxia/xiaoxia-saas
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
- name: Get staging running images (whitelist)
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
jobs:
ci-health-report:
name: CI健康度每日巡检
runs-on: saas
runs-on: ci-l2
timeout-minutes: 15
steps:
- name: Checkout code
+41 -10
View File
@@ -58,24 +58,56 @@ jobs:
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
# 分页获取所有变更文件(修复>300文件时漏判)
ALL_FILES=""
PAGE=1
while true; do
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300&page=${PAGE}"
PAGE_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; files=json.load(sys.stdin); [print(f['filename']) for f in files]; sys.exit(0 if len(files)==300 else 1)" 2>/dev/null || true)
PAGE_COUNT=$(echo "$PAGE_FILES" | wc -l)
if [ "$PAGE_COUNT" -lt 300 ]; then HAS_MORE=1; else HAS_MORE=0; fi
ALL_FILES="${ALL_FILES}${PAGE_FILES}"$'\n'
if [ "$HAS_MORE" != "0" ]; then
break
fi
PAGE=$((PAGE + 1))
done
FRONTEND_COUNT=$(echo "$ALL_FILES" | grep -c '^apps/web/' || true)
TOTAL=$(echo "$ALL_FILES" | grep -cv '^$' || true)
BACKEND_COUNT=$(( TOTAL - FRONTEND_COUNT ))
# 基础设施文件:改了就强制全量CI(不跳过任何检查)
INFRA_COUNT=$(echo "$ALL_FILES" | grep -cE '^(infra/|Dockerfile|\.gitea/workflows/|scripts/ci/|docker/)' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT}, 基础设施: ${INFRA_COUNT})"
# 判定是否纯前端/纯后端
PURE_FRONTEND=false
PURE_BACKEND=false
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then
PURE_FRONTEND=true
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then
PURE_BACKEND=true
fi
if [ "$PURE_FRONTEND" = "true" ]; then
echo "skip_backend=true" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
echo "✅ 纯前端改动,跳过后端检查"
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then
elif [ "$PURE_BACKEND" = "true" ]; then
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=true" >> $GITHUB_OUTPUT
echo "🔧 纯后端改动,跳过前端检查"
else
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
echo "🔧 包含全栈变更,运行完整CI"
if [ "$INFRA_COUNT" -gt "0" ]; then
echo "🏗️ 包含基础设施变更,强制运行完整CI"
else
echo "🔧 包含全栈变更,运行完整CI"
fi
fi
- name: Report CI trace
@@ -1588,7 +1620,6 @@ jobs:
set -eu
python3 scripts/ci/acr_cleanup.py \
--keep 20 \
--pr-days 7 \
--execute
- name: Job duration summary
+6 -6
View File
@@ -16,15 +16,15 @@ permissions:
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ubuntu-latest
runs-on: ci-l2
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v3
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Check CI trigger status for all open PRs
env:
+6 -8
View File
@@ -15,20 +15,18 @@ concurrency:
jobs:
code-review:
name: AI Code Review
runs-on: ubuntu-latest
runs-on: ci-l2
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Install dependencies
run: |
+32 -134
View File
@@ -1,4 +1,5 @@
name: Daily Health Check
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
on:
schedule:
@@ -12,7 +13,7 @@ jobs:
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke:
name: Production Smoke Test
runs-on: saas
runs-on: ci-l2
timeout-minutes: 8
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -23,47 +24,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
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
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Production health check & smoke test
id: smoke
shell: sh
@@ -121,7 +84,7 @@ jobs:
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests:
name: Staging API Integration Tests
runs-on: saas
runs-on: ci-l2
timeout-minutes: 10
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -132,50 +95,15 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
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
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Run API smoke test on staging
id: smoke
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
@@ -183,8 +111,8 @@ jobs:
docker run --rm \
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
-e WEB_URL=https://staging.xiaoxiajianji.com \
-e TEST_USER=18314979086@163.com \
-e TEST_PASSWORD=Ying1234 \
-e TEST_USER="$STAGING_TEST_USER" \
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
-e CLEANUP_ENABLED=1 \
-e PERF_CHECK_ENABLED=1 \
-e PERF_WARN_THRESHOLD_MS=500 \
@@ -270,7 +198,7 @@ jobs:
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e:
name: Staging Browser E2E
runs-on: saas
runs-on: ci-l2
timeout-minutes: 15
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -281,47 +209,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
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
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Run Playwright E2E on staging
id: e2e
shell: sh
@@ -371,7 +261,7 @@ jobs:
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: saas
runs-on: ci-l2
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
@@ -380,6 +270,9 @@ jobs:
- name: Run performance baseline checks
id: perf
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
@@ -415,9 +308,10 @@ jobs:
# 先登录获取 token
echo "--- 准备: 获取测试 Token ---"
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
-d "$LOGIN_BODY" \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -447,7 +341,7 @@ jobs:
# 构建 curl 命令
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -495,6 +389,9 @@ jobs:
- name: Generate performance report
id: report
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
echo ""
@@ -509,10 +406,11 @@ jobs:
RESULTS=""
START_TIME=$(date +%s)
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
# 先登录获取 token
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
-d "$LOGIN_BODY" \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -528,7 +426,7 @@ jobs:
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -631,7 +529,7 @@ jobs:
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: saas
runs-on: ci-l2
timeout-minutes: 2
if: always()
needs:
+12 -199
View File
@@ -14,10 +14,12 @@ from __future__ import annotations
import json
import logging
import math
import random
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
logger = logging.getLogger(__name__)
@@ -64,85 +66,9 @@ def _generate_titles_fallback(
style: str = "viral",
count: int = 5,
) -> List[str]:
"""本地降级:基于模板规则生成标题.
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
"""
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
examples = style_info["examples"]
# 从描述中提取关键词(取前几个词)
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
return _generate_titles_fallback_base(description, style_info, count)
def generate_smart_titles(
@@ -241,132 +167,19 @@ def _semantic_match_fallback(
description: str,
assets: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""本地降级:基于关键词的简单匹配.
计算描述中的关键词与素材名称/标签/描述的重叠度,
作为匹配度评分。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
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
return _semantic_match_fallback_base(description, assets)
def _parse_semantic_match_response(
content: str,
asset_ids: List[str],
) -> Optional[Dict[str, float]]:
"""从模型返回中解析素材匹配度.
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
score 范围 0-1。
"""
if not content:
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
result = _parse_semantic_match_base(content, asset_ids)
if result is None:
return None
# 尝试解析 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
return dict(result)
def semantic_match_assets(
+52 -63
View File
@@ -16,6 +16,11 @@ from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanRepository,
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_clip import EditPlanClip, EditPlanClipStatus
@@ -384,36 +389,45 @@ class EditPlanService:
clip = self.get_clip_or_raise(clip_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)
original_duration = clip.duration
left_duration = round(split_time, 3)
right_duration = round(original_duration - split_time, 3)
original_order = clip.order
# 更新左半部分(原片段)
clip.duration = left_duration
clip.duration = split.left_duration
left_clip = self._clip_repo.update(clip)
# 后面片段的 order 全部 +1(给右半部分腾位置)
all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips:
if c.order > original_order and c.id != clip_id:
c.order += 1
self._clip_repo.update(c)
shifts = _calc_shift_orders(
all_clips,
threshold_order=original_order,
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 {}
# 素材裁剪信息
if clip.asset_id:
# 右半部分从 split_time 开始播放
right_config["trim_start"] = left_duration
right_config["trim_start"] = split.right_trim_start
# 左半部分在 split_time 处结束
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 = self._clip_repo.update(left_clip)
@@ -424,8 +438,8 @@ class EditPlanService:
template_clip_config_id=clip.template_clip_config_id,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=clip.start_time + left_duration,
duration=right_duration,
start_time=split.right_start_time,
duration=split.right_duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
@@ -438,8 +452,8 @@ class EditPlanService:
clip_id,
plan_id,
split_time,
left_duration,
right_duration,
split.left_duration,
split.right_duration,
)
return {
@@ -468,70 +482,45 @@ class EditPlanService:
clip = self.get_clip_or_raise(cid)
clips.append(clip)
# 校验:同一计划
plan_id = clips[0].plan_id
for c in clips[1:]:
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("只能合并相同类型的片段")
# 纯逻辑:校验 + 计算
plan_id, first_order = _validate_merge(clips)
merge = _calc_merge(clips)
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.text_content = merged_text
first_clip.config = merged_config
first_clip = sorted(clips, key=lambda c: c.order)[0]
first_clip.duration = merge.total_duration
first_clip.text_content = merge.merged_text
first_clip.config = merge.merged_config
# 转场保留第一个的(合并后的入点转场)
# playback_speed 取第一个的
merged_clip = self._clip_repo.update(first_clip)
# 删除其余片段
for c in clips[1:]:
self._clip_repo.delete(c.id)
rest_ids = [c.id for c in clips if c.id != merged_clip.id]
for cid in rest_ids:
self._clip_repo.delete(cid)
# 后面的片段 order 前移 (len - 1) 位
shift = len(clips) - 1
all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips:
if c.order > first_order and c.id != merged_clip.id:
c.order -= shift
self._clip_repo.update(c)
shifts = _calc_shift_orders(
all_clips,
threshold_order=first_order,
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(
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
plan_id,
len(clips),
total_duration,
merge.total_duration,
)
return merged_clip
+15 -161
View File
@@ -23,6 +23,13 @@ from packages.domain.template_clip_config import (
TemplateClipConfig,
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__)
@@ -121,9 +128,7 @@ class EditTemplateService:
ValueError: 名称为空或重复
"""
# 名称校验
clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
clean_name = validate_template_name(name)
# 名称重复检查
existing = self._template_repo.list_all(skip=0, limit=1000)
@@ -471,12 +476,7 @@ class EditTemplateService:
raise ValueError(f"模板名称已存在: {clean_name}")
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
plan_config = plan.config or {}
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_config = filter_plan_config_to_template(plan.config)
template = EditTemplate.create(
name=clean_name,
@@ -497,40 +497,7 @@ class EditTemplateService:
# 5. 转换每个片段为模板片段配置
created_configs: List[TemplateClipConfig] = []
for clip in 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,
)
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
created = self._clip_config_repo.create(clip_config_obj)
created_configs.append(created)
@@ -679,8 +646,6 @@ class EditTemplateService:
Raises:
ValueError: 模板/草稿不存在,或草稿不属于该模板
"""
from packages.domain.template_clip_config import TemplateClipConfig
# 1. 校验模板和草稿
template = self.get_template_or_raise(template_id)
draft = self._plan_repo.get(draft_plan_id)
@@ -700,39 +665,14 @@ class EditTemplateService:
editing_mode = config.get("editing_mode", "one_take")
# 4. 提取模板配置(去掉草稿/运行时字段)
draft_config = draft.config or {}
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
template_config = filter_plan_config_to_template(draft.config)
# 5. 事务更新
try:
# 5.0 先保存旧版快照(发布前的状态),用于回滚
old_version = template.version or 1
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [
{
"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
]
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
from packages.domain.template_version import EditTemplateVersion
@@ -759,46 +699,7 @@ class EditTemplateService:
# 创建新的片段配置
created_configs: list[TemplateClipConfig] = []
for clip in 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,
)
for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
created = self._clip_config_repo.create(config_obj)
created_configs.append(created)
@@ -843,8 +744,6 @@ class EditTemplateService:
Raises:
ValueError: 模板/版本不存在
"""
from packages.domain.template_clip_config import TemplateClipConfig
template = self.get_template_or_raise(template_id)
# 1. 读取目标版本快照
@@ -857,22 +756,7 @@ class EditTemplateService:
try:
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [
{
"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
]
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
from packages.domain.template_version import EditTemplateVersion
@@ -905,37 +789,7 @@ class EditTemplateService:
synchronize_session=False
)
for clip_snap in 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 {},
)
for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
self._clip_config_repo.create(config_obj)
self._db.commit()
+20 -250
View File
@@ -26,7 +26,13 @@ from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.edit_template import EditTemplate
from packages.domain.editing_mode import EditingMode
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
from packages.domain.plan_generator_utils import (
create_clips_from_configs,
distribute_assets,
generate_default_clips,
map_clip_types_for_mode,
)
from packages.domain.template_clip_config import TemplateClipConfig
logger = logging.getLogger(__name__)
@@ -163,83 +169,18 @@ class PlanGeneratorService:
plan_id: str,
clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
clips: List[EditPlanClip] = []
# 按 order 排序
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
for cfg in sorted_configs:
# 计算时长:取 min_duration 和 max_duration 的中间值
if cfg.min_duration > 0 and cfg.max_duration > 0:
duration = (cfg.min_duration + cfg.max_duration) / 2
elif cfg.min_duration > 0:
duration = cfg.min_duration
elif cfg.max_duration > 0:
duration = cfg.max_duration
else:
duration = _DEFAULT_CLIP_DURATION
# clip_type 可能是枚举或字符串
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串
transition = (
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
)
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
clip_cfg = cfg.config or {}
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
text_content=getattr(cfg, "text_template", "") or "",
duration=duration,
transition_effect=transition or "cut",
playback_speed=playback_speed,
config=clip_cfg,
)
clips.append(clip)
return clips
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
"""
return create_clips_from_configs(plan_id, clip_configs)
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
"""模板 clip_config 生成的 MAIN 类型片段按 editing_mode 映射为对应角色类型
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
overlay / background / corner_voice / b_roll)。
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
- ONE_TAKE / VOICE_OVER: 保持 main 不变
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
"""
from packages.domain.template_clip_config import ClipType
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if not main_clips:
return
if editing_mode == EditingMode.PIP.value:
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
for i, clip in enumerate(main_clips):
if i > 0:
clip.clip_type = "overlay"
elif editing_mode == EditingMode.VOICE_PIP.value:
for i, clip in enumerate(main_clips):
if i == 0:
clip.clip_type = "background"
elif i == 1:
clip.clip_type = "corner_voice"
else:
clip.clip_type = "b_roll"
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
map_clip_types_for_mode(clips, editing_mode)
def _generate_default_clips(
self,
@@ -247,101 +188,11 @@ class PlanGeneratorService:
editing_mode: str,
asset_count: int,
) -> List[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
- ONE_TAKE: N 个 main clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = asset_count
- VOICE_OVER: N 个 main clips + 标记需要配音
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
委托给 plan_generator_utils.generate_default_clips 纯函数。
"""
n = max(asset_count, 1)
clips: List[EditPlanClip] = []
order = 0
if editing_mode == EditingMode.PIP.value:
# 1 个 main(全屏背景)
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 overlay
for _ in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="overlay",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="corner_voice",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 b_roll
for _ in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="b_roll",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
else:
# ONE_TAKE: N 个 main clips
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
return clips
return generate_default_clips(plan_id, editing_mode, asset_count)
def _distribute_assets(
self,
@@ -349,89 +200,8 @@ class PlanGeneratorService:
asset_ids: List[str],
editing_mode: str,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
分配策略:
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
- VOICE_OVER: 素材→main clips (B-roll)
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
委托给 plan_generator_utils.distribute_assets 纯函数。
"""
if not asset_ids or not clips:
return
if editing_mode == EditingMode.ONE_TAKE.value:
self._distribute_one_take(clips, asset_ids)
elif editing_mode == EditingMode.PIP.value:
self._distribute_pip(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_OVER.value:
self._distribute_voice_over(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_PIP.value:
self._distribute_voice_pip(clips, asset_ids)
else:
# 未知模式,退化为 one_take
self._distribute_one_take(clips, asset_ids)
def _distribute_one_take(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
main_clips[0].assign_asset(asset_ids[0])
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
remaining = asset_ids[1:]
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
def _distribute_voice_over(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_voice_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
bg_clips = [c for c in clips if c.clip_type == "background"]
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
# 第1个素材 → background
if bg_clips and len(asset_ids) > 0:
bg_clips[0].assign_asset(asset_ids[0])
# 第2个素材 → corner_voice
if corner_clips and len(asset_ids) > 1:
corner_clips[0].assign_asset(asset_ids[1])
# 其余素材 → b_roll
remaining = asset_ids[2:]
for i, clip in enumerate(broll_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
distribute_assets(clips, asset_ids, editing_mode)
+48 -210
View File
@@ -13,57 +13,26 @@
- 最低质量分门槛:自动过滤低质量素材
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
- 兼容全部模式:素材库模式和项目模式都可用
纯逻辑部分已抽离到 packages.domain.asset_scoring。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
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 (
AssetScoreDetail,
SmartSelectResult,
diverse_selection,
filter_candidates,
score_asset_detail,
)
logger = logging.getLogger(__name__)
# ── 评分权重 ──────────────────────────────────────────────────────────────────
_WEIGHT_QUALITY = 0.5
_WEIGHT_RESOLUTION = 0.2
_WEIGHT_DURATION = 0.2
_WEIGHT_BITRATE = 0.1
# ── 评分参数 ──────────────────────────────────────────────────────────────────
_TARGET_WIDTH = 1920 # 目标分辨率宽度基准
_TARGET_HEIGHT = 1080 # 目标分辨率高度基准
_MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
_OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
_OPTIMAL_DURATION_MAX = 30.0
# ── 多样性分桶 ───────────────────────────────────────────────────────────────
_SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
_MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
# 长素材:> 15s
@dataclass
class SmartSelectResult:
"""智能选择结果."""
selected_ids: list[str]
total_candidates: int
filtered_out: int # 被质量门槛过滤的数量
avg_score: float
details: list[AssetScoreDetail]
@dataclass
class AssetScoreDetail:
"""单个素材的评分详情."""
asset_id: str
total_score: float
quality_score: float
resolution_score: float
duration_score: float
bitrate_score: float
duration: float | None
class SmartAssetSelector:
"""智能素材选择器.
@@ -74,9 +43,9 @@ class SmartAssetSelector:
def __init__(
self,
min_quality_score: float = _MIN_QUALITY_SCORE,
target_width: int = _TARGET_WIDTH,
target_height: int = _TARGET_HEIGHT,
min_quality_score: float = 30.0,
target_width: int = 1920,
target_height: int = 1080,
):
self.min_quality_score = min_quality_score
self.target_width = target_width
@@ -102,21 +71,7 @@ class SmartAssetSelector:
SmartSelectResult 选择结果
"""
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
candidates = []
filtered_out = 0
for asset in assets:
status = getattr(asset, "status", None)
status_val = status.value if hasattr(status, "value") else str(status)
if status_val != "ready":
continue
mime_type = getattr(asset, "mime_type", "") or ""
if not mime_type.startswith("video"):
continue
quality = getattr(asset, "quality_score", None)
if quality is not None and quality < self.min_quality_score:
filtered_out += 1
continue
candidates.append(asset)
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
if not candidates:
return SmartSelectResult(
@@ -130,7 +85,16 @@ class SmartAssetSelector:
# 2. 对每个候选素材评分
scored: list[AssetScoreDetail] = []
for asset in candidates:
detail = self._score_asset(asset)
detail = score_asset_detail(
asset_id=asset.id,
quality=getattr(asset, "quality_score", None),
width=getattr(asset, "width", None),
height=getattr(asset, "height", None),
duration=getattr(asset, "duration", None),
file_size=getattr(asset, "file_size", 0) or 0,
target_width=self.target_width,
target_height=self.target_height,
)
scored.append(detail)
# 3. 按总分降序排列
@@ -138,7 +102,7 @@ class SmartAssetSelector:
# 4. 多样性选择(如果需要且数量有限制)
if ensure_diversity and count > 0 and len(scored) > count:
selected = self._diverse_selection(scored, count)
selected = diverse_selection(scored, count)
else:
# 无数量限制或不要求多样性,直接按排名取
selected = scored if count <= 0 else scored[:count]
@@ -162,165 +126,39 @@ class SmartAssetSelector:
)
return result
# ── 内部方法 ──────────────────────────────────────────────────────────────
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
def _score_asset(self, asset) -> AssetScoreDetail:
"""对单个素材进行多维度评分."""
# 质量分
quality = getattr(asset, "quality_score", None)
quality_score = (quality / 100.0) if quality is not None else 0.5
# 分辨率评分:越接近目标分辨率得分越高
width = getattr(asset, "width", None)
height = getattr(asset, "height", None)
resolution_score = self._score_resolution(width, height)
# 时长评分:在最佳区间内得分高,过短过长扣分
duration = getattr(asset, "duration", None)
duration_score = self._score_duration(duration)
# 码率评分:用 file_size/duration 估算,适中得分高
file_size = getattr(asset, "file_size", 0) or 0
bitrate_score = self._score_bitrate(file_size, duration)
# 加权总分
total = (
_WEIGHT_QUALITY * quality_score
+ _WEIGHT_RESOLUTION * resolution_score
+ _WEIGHT_DURATION * duration_score
+ _WEIGHT_BITRATE * bitrate_score
)
return AssetScoreDetail(
"""对单个素材进行多维度评分(向后兼容)."""
return score_asset_detail(
asset_id=asset.id,
total_score=round(total, 4),
quality_score=round(quality_score, 4),
resolution_score=round(resolution_score, 4),
duration_score=round(duration_score, 4),
bitrate_score=round(bitrate_score, 4),
duration=duration,
quality=getattr(asset, "quality_score", None),
width=getattr(asset, "width", None),
height=getattr(asset, "height", None),
duration=getattr(asset, "duration", None),
file_size=getattr(asset, "file_size", 0) or 0,
target_width=self.target_width,
target_height=self.target_height,
)
def _score_resolution(self, width: int | None, height: int | None) -> float:
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
if width is None or height is None or width <= 0 or height <= 0:
return 0.5 # 未知分辨率给中评分
"""分辨率评分(向后兼容)."""
from packages.domain.asset_scoring import score_resolution
target_pixels = self.target_width * self.target_height
actual_pixels = width * height
# 计算像素数比例
ratio = actual_pixels / target_pixels
if ratio >= 1.0:
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
return 1.0
else:
# 低于目标分辨率:线性衰减,但最低不低于 0.1
# 例如:720p (921600) / 1080p (2073600) = 0.44 → 得分 0.6
score = 0.3 + 0.7 * ratio
return max(0.1, min(1.0, score))
return score_resolution(width, height, self.target_width, self.target_height)
def _score_duration(self, duration: float | None) -> float:
"""时长评分3-30秒最佳,过短或过长都扣分."""
if duration is None or duration <= 0:
return 0.5 # 未知时长给中评分
"""时长评分(向后兼容)."""
from packages.domain.asset_scoring import score_duration
if _OPTIMAL_DURATION_MIN <= duration <= _OPTIMAL_DURATION_MAX:
# 最佳区间:满分
return 1.0
if duration < _OPTIMAL_DURATION_MIN:
# 太短:线性衰减,1秒以下给 0.3
ratio = duration / _OPTIMAL_DURATION_MIN
return 0.3 + 0.7 * ratio
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
excess = duration - _OPTIMAL_DURATION_MAX
penalty = min(0.8, excess / 10.0 * 0.1)
return max(0.2, 1.0 - penalty)
return score_duration(duration)
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
if not file_size or not duration or duration <= 0:
return 0.5 # 未知给中评分
"""码率评分(向后兼容)."""
from packages.domain.asset_scoring import score_bitrate
# 估算码率(bps
bitrate = (file_size * 8) / duration
# 最佳码率范围:2-8 Mbps
optimal_low = 2_000_000 # 2 Mbps
optimal_high = 8_000_000 # 8 Mbps
if optimal_low <= bitrate <= optimal_high:
return 1.0
if bitrate < optimal_low:
# 码率太低:线性衰减
ratio = bitrate / optimal_low
return 0.3 + 0.7 * ratio
# 码率太高(文件太大):适度扣分
excess = bitrate / optimal_high - 1.0
penalty = min(0.5, excess * 0.2)
return max(0.5, 1.0 - penalty)
return score_bitrate(file_size, duration)
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
"""多样性选择:按时长分桶,保证每个桶都有素材.
策略:
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>15s)
2. 每个桶配额 = max(1, count / 3)
3. 先从每桶按配额取最高分的
4. 剩余名额从全局最高分中取(不重复)
"""
# 分桶
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
medium_bucket = [
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
]
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
unknown_bucket = [d for d in scored if d.duration is None]
buckets = [short_bucket, medium_bucket, long_bucket]
bucket_names = ["short", "medium", "long"]
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
base_quota = max(1, count // 3)
selected: list[AssetScoreDetail] = []
selected_ids: set[str] = set()
# 先按配额从每个桶取
for bucket, _name in zip(buckets, bucket_names, strict=False):
quota = min(base_quota, len(bucket))
if quota <= 0:
continue
# 桶内已经按分数排好序了,直接取前 quota 个
for item in bucket[:quota]:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
return selected
# 剩余名额:从全局(未被选中的)中按分数高低取
remaining_needed = count - len(selected)
if remaining_needed > 0:
for item in scored:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
# 如果还不够(不应该发生),加上未知时长的
if len(selected) < count and unknown_bucket:
for item in unknown_bucket:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
return selected[:count]
"""多样性选择(向后兼容)."""
return diverse_selection(scored, count)
+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_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__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# ── 常量(向后兼容别名) ──────────────────────────────────────────────────────
# 实际定义已迁移至 packages/domain/video_filter_builder.py
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
DEFAULT_CODEC = "libx264"
DEFAULT_CRF = 23
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)
class ComposeCommand:
"""完整的 FFmpeg 合成命令描述。"""
@@ -401,62 +387,8 @@ class VideoComposeService:
output_height: int,
fps: int,
) -> ClipFilterChain:
"""为单个片段构建滤镜链。
滤镜顺序:
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,
)
"""向后兼容:委托给 video_filter_builder.build_clip_filter。"""
return build_clip_filter(clip, input_index, output_width, output_height, fps)
@staticmethod
def _build_filter_complex(
@@ -466,102 +398,30 @@ class VideoComposeService:
transition_duration: float,
transitions: list[str],
) -> tuple[str, float]:
"""构建完整的 filter_complex 字符串。
策略:
- 单片段:直接输出
- 多片段 + 全 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,
)
"""向后兼容:委托给 video_filter_builder.build_filter_complex"""
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
@staticmethod
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
"""是否有任何片段包含音频流"""
return any(c.audio_label is not None for c in clip_chains)
"""向后兼容:委托给 video_filter_builder.has_audio"""
return _has_audio_func(clip_chains)
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
# ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
# 实际实现已迁移至 packages/domain/video_filter_builder.py
# 保留此处别名以兼容现有测试与调用方
def _chain_filters(filters: list[str], output_label: str) -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串"""
filter_body = ",".join(filters)
return f"[0:v]{filter_body}[{output_label}]"
"""向后兼容:委托给 video_filter_builder.chain_filters"""
return _chain_filters_func(filters, output_label)
def _build_concat_filter(
clip_chains: list[ClipFilterChain],
) -> tuple[str, float]:
"""构建 concat 滤镜(无转场,高效拼接)。
格式:
[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
"""向后兼容:委托给 video_filter_builder.build_concat_filter。"""
return _build_concat_filter_func(clip_chains)
def _build_xfade_filter(
@@ -569,80 +429,5 @@ def _build_xfade_filter(
transition_duration: float,
transitions: list[str],
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链。
每两个相邻片段之间插入 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)
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
+26
View File
@@ -0,0 +1,26 @@
/**
* 订阅 API — 目录化入口
* 保持与原 subscription.ts 相同导出,向后兼容
*/
// 类型
export type {
PlanType,
SubscriptionStatus,
BillingStatus,
BillingCycle,
Plan,
SubscriptionInfo,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
} from "./types"
// API 函数
export {
getCurrentSubscription,
getBillingRecords,
changePlan,
cancelSubscription,
toggleAutoRenew,
} from "./subscription"
@@ -0,0 +1,47 @@
/**
* 订阅相关 API 函数
*/
import apiClient from "../client"
import type {
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
SubscriptionInfo,
} from "./types"
/** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
const response = await apiClient.get("/subscription/current")
return response.data
}
/** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
const response = await apiClient.get("/subscription/billing-records")
return response.data
}
/** 升级/降级套餐 */
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
const response = await apiClient.post("/subscription/change-plan", request)
return response.data
}
/** 取消订阅 */
export const cancelSubscription = async (): Promise<{
success: boolean
message: string
}> => {
const response = await apiClient.post("/subscription/cancel")
return response.data
}
/** 切换自动续费 */
export const toggleAutoRenew = async (
enabled: boolean,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled,
})
return response.data
}
@@ -1,8 +1,6 @@
/**
* API
*
*
*/
import apiClient from "./client"
/** 套餐类型 */
export type PlanType = "free" | "standard" | "pro" | "enterprise"
@@ -65,42 +63,3 @@ export interface ChangePlanResponse {
message: string
new_subscription?: SubscriptionInfo
}
// ============ API 函数 ============
/** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
const response = await apiClient.get("/subscription/current")
return response.data
}
/** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
const response = await apiClient.get("/subscription/billing-records")
return response.data
}
/** 升级/降级套餐 */
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
const response = await apiClient.post("/subscription/change-plan", request)
return response.data
}
/** 取消订阅 */
export const cancelSubscription = async (): Promise<{
success: boolean
message: string
}> => {
const response = await apiClient.post("/subscription/cancel")
return response.data
}
/** 切换自动续费 */
export const toggleAutoRenew = async (
enabled: boolean,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled,
})
return response.data
}
@@ -1,15 +1,21 @@
/**
* 模板编辑器 — 制作/编辑剪辑模板
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*
* 主组件仅保留 Hook 组装与整体布局
* 全局配置 → hooks/useGlobalSettings
* 配音素材 → hooks/useVoiceMaterials
* 撤销重做 → hooks/useUndoRedo
* 抽屉管理 → hooks/useEditorDrawers
* 播放控制 → hooks/usePlaybackControl
* 片段操作 → hooks/useClipOperations
* 模板管理 → hooks/useTemplateManagement
*/
import React, { useState } from "react"
import { useSearchParams } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { MODE_LABELS } from "@/api/editing-planner"
import { MODE_LIST } from "./constants"
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { MediaAsset } from "@/api/template-editor"
import MediaPanel from "./components/MediaPanel"
import PreviewPlayer from "./components/PreviewPlayer"
@@ -26,36 +32,12 @@ import { useEditorDrawers } from "./hooks/useEditorDrawers"
import { usePlaybackControl } from "./hooks/usePlaybackControl"
import { useClipOperations } from "./hooks/useClipOperations"
import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement"
import { useGlobalSettings } from "./hooks/useGlobalSettings"
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
import type {
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 type { ClipData } from "./types"
import "./EditingPlanner.css"
/* ──────────── 常量 ──────────── */
/* ──────────── 组件 ──────────── */
const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams()
const urlTemplateId = searchParams.get("templateId") || ""
@@ -72,50 +54,29 @@ const EditingPlanner: React.FC = () => {
reset: resetClips,
} = useUndoRedo<ClipData[]>([])
/* ── 全局配置 state ── */
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,
})
/* ── 全局配置 ── */
const {
titleConfig,
setTitleConfig,
subtitleSettings,
setSubtitleSettings,
bgmSettings,
setBgmSettings,
watermarkSettings,
setWatermarkSettings,
introOutroSettings,
setIntroOutroSettings,
pipSettings,
setPipSettings,
filterSettings,
setFilterSettings,
chromaKeySettings,
setChromaKeySettings,
stickerSettings,
setStickerSettings,
coverConfig,
setCoverConfig,
} = useGlobalSettings()
/* ── 右侧栏 Tab ── */
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
@@ -128,18 +89,12 @@ const EditingPlanner: React.FC = () => {
setSelectedAssetIds(ids)
}
/* ── 配音素材queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
const voiceMaterialsQuery = 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,
})
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? []
/* ── 配音素材 ── */
const {
voiceMaterials,
loading: voiceMaterialsLoading,
refetch: refetchVoiceMaterials,
} = useVoiceMaterials()
/* ── 派生计算 ── */
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0)
@@ -179,31 +134,6 @@ const EditingPlanner: React.FC = () => {
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 (
@@ -294,15 +224,15 @@ const EditingPlanner: React.FC = () => {
totalDuration={totalDuration}
currentMode={tpl.currentMode}
onSubtitleSettingsChange={(partial) =>
setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig)
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
}
onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))}
onClipUpdate={clipOps.handleClipUpdate}
onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)}
onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
voiceMaterialsLoading={voiceMaterialsLoading}
onRefreshVoiceMaterials={refetchVoiceMaterials}
onClipVoiceSelect={clipOps.handleClipVoiceSelect}
onOpenTransitionDrawer={drawers.openTransitionDrawer}
onOpenSpeedDrawer={drawers.openSpeedDrawer}
@@ -382,28 +312,28 @@ const EditingPlanner: React.FC = () => {
onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)}
watermarkDrawerOpen={drawers.watermarkDrawerOpen}
watermarkSettings={watermarkSettings}
onWatermarkChange={handleWatermarkChange}
onWatermarkChange={setWatermarkSettings}
onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)}
introOutroDrawerOpen={drawers.introOutroDrawerOpen}
introOutroSettings={introOutroSettings}
onIntroOutroChange={handleIntroOutroChange}
onIntroOutroChange={setIntroOutroSettings}
onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)}
pipDrawerOpen={drawers.pipDrawerOpen}
pipSettings={pipSettings}
totalDuration={totalDuration}
onPipChange={handlePipChange}
onPipChange={setPipSettings}
onClosePipDrawer={() => drawers.setPipDrawerOpen(false)}
filterDrawerOpen={drawers.filterDrawerOpen}
filterSettings={filterSettings}
onFilterChange={handleFilterChange}
onFilterChange={setFilterSettings}
onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)}
chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen}
chromaKeySettings={chromaKeySettings}
onChromaKeyChange={handleChromaKeyChange}
onChromaKeyChange={setChromaKeySettings}
onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)}
stickerDrawerOpen={drawers.stickerDrawerOpen}
stickerSettings={stickerSettings}
onStickerChange={handleStickerChange}
onStickerChange={setStickerSettings}
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
/>
</div>
@@ -2,99 +2,13 @@
* 右栏设置面板 — V8 原型 1:1 还原
* 字幕设置 + BGM设置 + 片段详情
*/
import React, { useRef, useState, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import type { TemplateMode } from "@/api/editing-planner"
import type { ClipData, ClipType } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type { AssetItem } from "@/api/assets"
interface SubtitleSettings {
enabled: boolean
position: string
font: string
fontSize: number
fontColor: string
animation: string
mode?: string
stroke?: boolean
shadow?: boolean
asrLanguage?: string
}
interface BgmSettings {
enabled: boolean
music_id: string
volume?: number
fade_in?: number
fade_out?: number
voice_dodge?: boolean
}
interface ClipPropertiesPanelProps {
selectedClip: ClipData | null
subtitleSettings: SubtitleSettings
bgmSettings: BgmSettings
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
/** 打开 BGM 选择器 Drawer */
onOpenBgmDrawer?: () => void
/** 打开字幕样式配置 Drawer */
onOpenSubtitleDrawer?: () => void
/** 配音素材列表(从配音库 API 获取) */
voiceMaterials?: AssetItem[]
/** 配音素材加载中 */
voiceMaterialsLoading?: boolean
/** 刷新配音素材列表 */
onRefreshVoiceMaterials?: () => void
/** 为片段选择配音素材 */
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
/** 打开转场特效选择器 Drawer */
onOpenTransitionDrawer?: (clipId: string) => void
/** 打开片段调速面板 Drawer */
onOpenSpeedDrawer?: (clipId: string) => void
/** 打开 TTS 配音面板 Drawer */
onOpenTtsDrawer?: (clipId: string) => void
/** 打开水印设置面板 Drawer */
onOpenWatermarkDrawer?: () => void
/** 打开片头片尾设置面板 Drawer */
onOpenIntroOutroDrawer?: () => void
/** 打开混剪设置面板 Drawer */
onOpenPipDrawer?: () => void
/** 打开滤镜调色面板 Drawer */
onOpenFilterDrawer?: () => void
/** 打开绿幕抠像面板 Drawer */
onOpenGreenScreenDrawer?: () => void
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void
/** 打开封面选择器 Drawer */
}
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
/** 片段类型图标/标签 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
import React from "react"
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
import ClipDetailSection from "./clip-properties/ClipDetailSection"
import StatsSection from "./clip-properties/StatsSection"
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
@@ -122,165 +36,19 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
}) => {
const navigate = useNavigate()
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
/* ── 配音试听 ── */
const audioRef = useRef<HTMLAudioElement | null>(null)
const [previewingId, setPreviewingId] = useState<string | null>(null)
/** 试听配音素材 */
const handlePreviewVoice = useCallback(
(asset: AssetItem) => {
// 点击同一个 → 暂停
if (previewingId === asset.id) {
audioRef.current?.pause()
setPreviewingId(null)
return
}
// 停止上一个
audioRef.current?.pause()
const url = asset.file_url || (asset.metadata?.preview_url as string)
if (!url) return
const audio = new Audio(url)
audioRef.current = audio
audio.play().catch(() => {})
audio.onended = () => setPreviewingId(null)
setPreviewingId(asset.id)
},
[previewingId],
)
/** 从 metadata 取性别标签 */
const getGenderLabel = (m: AssetItem): string => {
const g = (m.metadata?.gender as string) || ""
if (g === "male") return "男"
if (g === "female") return "女"
return ""
}
return (
<div className="ep-right-panel">
{/* ═══ 字幕设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">💬</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label"></span>
<div
className={`ep-toggle ${subtitleSettings.enabled ? "active" : ""}`}
onClick={() =>
onSubtitleSettingsChange({
enabled: !subtitleSettings.enabled,
})
}
>
<div className="ep-toggle-knob" />
</div>
</div>
{subtitleSettings.enabled && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.position}
onChange={(e) => onSubtitleSettingsChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.font}
onChange={(e) => onSubtitleSettingsChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={subtitleSettings.fontSize}
onChange={(e) =>
onSubtitleSettingsChange({
fontSize: Number(e.target.value),
})
}
/>
<span className="ep-slider-value">{subtitleSettings.fontSize}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.animation}
onChange={(e) => onSubtitleSettingsChange({ animation: e.target.value })}
>
{ANIMATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 高级配置按钮 */}
{onOpenSubtitleDrawer && (
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
🎨
</button>
)}
</>
)}
</div>
<SubtitleSettingsSection
settings={subtitleSettings}
onChange={onSubtitleSettingsChange}
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
/>
{/* ═══ BGM 设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎵</span>
BGM
</div>
{bgmSettings.enabled && bgmSettings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
{bgmSettings.volume !== undefined && (
<span className="ep-bgm-current-vol"> {bgmSettings.volume}%</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
<BgmSettingsSection settings={bgmSettings} onOpenBgmDrawer={onOpenBgmDrawer} />
{/* ═══ 水印设置 ═══ */}
<div className="ep-settings-section">
@@ -374,258 +142,30 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
{/* ═══ 片段详情(选中时显示) ═══ */}
{selectedClip && (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled =
currentMode === "pip"
? t !== "pip"
: currentMode === "voice_over"
? t !== "voice"
: false // voice_pip 可切换
return (
<button
key={t}
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(selectedClip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={selectedClip.duration}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{(() => {
const t = selectedClip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{selectedClip.speed ? `${selectedClip.speed.rate.toFixed(2)}x` : "1.00x"}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">
{(() => {
const tts = selectedClip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={selectedClip.startOffset}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={selectedClip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(selectedClip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(selectedClip.id, asset)
}
// 切换选择时停止试听
audioRef.current?.pause()
setPreviewingId(null)
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{/* 试听按钮 */}
{selectedClip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === selectedClip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find(
(m) => m.id === selectedClip.voice_asset_id,
)
if (asset) handlePreviewVoice(asset)
}}
>
{previewingId === selectedClip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
<ClipDetailSection
clip={selectedClip}
currentMode={currentMode}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onClipUpdate={onClipUpdate}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
previewingId={previewingId}
onPreviewVoice={handlePreviewVoice}
onStopPreview={stopPreview}
/>
)}
{/* ═══ 统计信息(始终显示) ═══ */}
{/* ═══ 统计信息(未选中时显示) ═══ */}
{!selectedClip && (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📊</span>
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{clipsCount}</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
{currentMode === "pip"
? "混剪"
: currentMode === "voice_over"
? "人物口播"
: currentMode === "one_take"
? "一镜到底"
: "口播+混剪"}
</div>
</div>
</div>
</div>
<StatsSection
clipsCount={clipsCount}
totalDuration={totalDuration}
currentMode={currentMode}
/>
)}
</div>
)
@@ -3,189 +3,32 @@
* 纯渲染层,业务逻辑和 state 留在父组件
*/
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 BgmSelector from "./BgmSelector"
import SubtitleStylePanel from "./SubtitleStylePanel"
import TransitionSelector from "./TransitionSelector"
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"
import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers"
import { GlobalDrawers } from "./editing-drawers/GlobalDrawers"
import type { EditingDrawersProps } from "./editing-drawers/types"
interface EditingDrawersProps {
/* 保存弹窗 */
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 */
bgmDrawerOpen: boolean
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
const EditingDrawers: React.FC<EditingDrawersProps> = (props) => {
const {
saveModalOpen,
saveLoading,
isUpdate,
draftName,
draftCategory,
draftTags,
categories,
estimatedDuration,
onNameChange,
onCategoryChange,
onTagsChange,
onSave,
onCancelSave,
clips,
} = props
return (
<>
{/* ═══ 保存弹窗 ═══ */}
{/* 保存弹窗 */}
<SaveModal
open={saveModalOpen}
loading={saveLoading}
@@ -202,100 +45,59 @@ const EditingDrawers: React.FC<EditingDrawersProps> = ({
onCancel={onCancelSave}
/>
{/* ═══ BGM 选择器 Drawer ═══ */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onChangeBgmSettings}
{/* 片段级抽屉(转场/调速/TTS */}
<ClipLevelDrawers
clips={clips}
transitionDrawerOpen={props.transitionDrawerOpen}
transitionTargetClipId={props.transitionTargetClipId}
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 ═══ */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onChangeSubtitleSettings}
/>
{/* ═══ 转场特效选择器 Drawer ═══ */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* ═══ 片段调速面板 Drawer ═══ */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={speedConfig}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* ═══ TTS 配音面板 Drawer ═══ */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={ttsConfig}
onChange={onTtsChange}
/>
)}
{/* ═══ 水印配置面板 ═══ */}
<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}
{/* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) */}
<GlobalDrawers
bgmDrawerOpen={props.bgmDrawerOpen}
bgmSettings={props.bgmSettings}
onCloseBgmDrawer={props.onCloseBgmDrawer}
onChangeBgmSettings={props.onChangeBgmSettings}
subtitleDrawerOpen={props.subtitleDrawerOpen}
subtitleSettings={props.subtitleSettings}
onCloseSubtitleDrawer={props.onCloseSubtitleDrawer}
onChangeSubtitleSettings={props.onChangeSubtitleSettings}
totalDuration={props.totalDuration}
watermarkDrawerOpen={props.watermarkDrawerOpen}
watermarkSettings={props.watermarkSettings}
onCloseWatermarkDrawer={props.onCloseWatermarkDrawer}
onWatermarkChange={props.onWatermarkChange}
introOutroDrawerOpen={props.introOutroDrawerOpen}
introOutroSettings={props.introOutroSettings}
onCloseIntroOutroDrawer={props.onCloseIntroOutroDrawer}
onIntroOutroChange={props.onIntroOutroChange}
pipDrawerOpen={props.pipDrawerOpen}
pipSettings={props.pipSettings}
onClosePipDrawer={props.onClosePipDrawer}
onPipChange={props.onPipChange}
filterDrawerOpen={props.filterDrawerOpen}
filterSettings={props.filterSettings}
onCloseFilterDrawer={props.onCloseFilterDrawer}
onFilterChange={props.onFilterChange}
chromaKeyDrawerOpen={props.chromaKeyDrawerOpen}
chromaKeySettings={props.chromaKeySettings}
onCloseChromaKeyDrawer={props.onCloseChromaKeyDrawer}
onChromaKeyChange={props.onChromaKeyChange}
stickerDrawerOpen={props.stickerDrawerOpen}
stickerSettings={props.stickerSettings}
onCloseStickerDrawer={props.onCloseStickerDrawer}
onStickerChange={props.onStickerChange}
/>
</>
)
@@ -4,8 +4,11 @@
*/
import React, { useCallback } from "react"
import { Drawer, Switch } from "antd"
import type { FilterConfig, FilterPreset } from "../types"
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types"
import type { FilterConfig, FilterPreset } from "@/pages/editing-planner/types"
import { DEFAULT_FILTER_CONFIG } from "@/pages/editing-planner/types"
import { PRESET_GRADIENTS } from "@/pages/editing-planner/constants/filter"
import FilterPresetGrid from "./filter/FilterPresetGrid"
import FilterManualAdjust from "./filter/FilterManualAdjust"
interface FilterPanelProps {
open: boolean
@@ -14,34 +17,6 @@ interface FilterPanelProps {
onChange: (config: FilterConfig) => void
}
/** 所有预设列表 */
const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
]
/** 预设对应的示例渐变色(用于视觉预览) */
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
}
const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChange }) => {
const update = useCallback(
(partial: Partial<FilterConfig>) => {
@@ -54,7 +29,6 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
/** 选择预设时重置手动参数 */
const handlePresetSelect = useCallback(
(preset: FilterPreset) => {
if (preset === "none") {
@@ -70,6 +44,13 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
[config.enabled, onChange],
)
const handleManualChange = useCallback(
(key: keyof FilterConfig, value: number) => {
onChange({ ...config, [key]: value })
},
[config, onChange],
)
return (
<Drawer
title="滤镜调色"
@@ -90,110 +71,10 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
</div>
{/* 预设滤镜选择 */}
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
</button>
))}
</div>
</div>
<FilterPresetGrid selectedPreset={config.preset} onPresetSelect={handlePresetSelect} />
{/* 手动调节 */}
<div className="filter-section">
<div className="filter-section-title"></div>
{/* 亮度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.brightness}
onChange={(e) => update({ brightness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.brightness}</span>
</div>
{/* 对比度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.contrast}
onChange={(e) => update({ contrast: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.contrast}</span>
</div>
{/* 饱和度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.saturation}
onChange={(e) => update({ saturation: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.saturation}</span>
</div>
{/* 色温 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.temperature}
onChange={(e) => update({ temperature: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.temperature}</span>
</div>
{/* 色调 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.tint}
onChange={(e) => update({ tint: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.tint}</span>
</div>
{/* 锐度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={0}
max={100}
value={config.sharpness}
onChange={(e) => update({ sharpness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.sharpness}</span>
</div>
</div>
<FilterManualAdjust config={config} onChange={handleManualChange} />
{/* 预览色块 */}
<div className="filter-section">
@@ -5,19 +5,14 @@
*/
import React, { useCallback } from "react"
import { Drawer } from "antd"
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
import { DEFAULT_INTRO_OUTRO } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type {
IntroOutroConfig,
IntroOutroItem,
IntroOutroKind,
} from "@/pages/editing-planner/types"
import { DEFAULT_INTRO_OUTRO } from "@/pages/editing-planner/types"
import IntroOutroBlock from "./intro-outro/IntroOutroBlock"
/* ──────────── 常量 ──────────── */
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
]
/* ──────────── Props ──────────── */
interface IntroOutroPanelProps {
open: boolean
onClose: () => void
@@ -73,210 +68,24 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({ open, onClose, config
className="intro-outro-panel-drawer"
>
{/* ═══ 片头区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🎞</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
onClick={() => handleIntroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.intro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.intro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.intro.kind === "video"
? "https://example.com/intro.mp4"
: "https://example.com/intro.png"
}
value={config.intro.url ?? ""}
onChange={(e) => handleIntroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.intro.duration}
onChange={(e) => handleIntroChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{config.intro.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<select
className="iop-select"
value={config.intro.transition ?? "none"}
onChange={(e) =>
handleIntroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.intro.transition && config.intro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.intro.transition_duration ?? 0.5}
onChange={(e) =>
handleIntroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
<IntroOutroBlock
title="片头"
icon="🎞️"
item={config.intro}
transitionLabel="进入过渡动画"
onKindChange={handleIntroKindChange}
onChange={handleIntroChange}
/>
{/* ═══ 片尾区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🏁</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
onClick={() => handleOutroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.outro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.outro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.outro.kind === "video"
? "https://example.com/outro.mp4"
: "https://example.com/outro.png"
}
value={config.outro.url ?? ""}
onChange={(e) => handleOutroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.outro.duration}
onChange={(e) => handleOutroChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{config.outro.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">退</label>
<select
className="iop-select"
value={config.outro.transition ?? "none"}
onChange={(e) =>
handleOutroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.outro.transition && config.outro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.outro.transition_duration ?? 0.5}
onChange={(e) =>
handleOutroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
<IntroOutroBlock
title="片尾"
icon="🏁"
item={config.outro}
transitionLabel="退出过渡动画"
onKindChange={handleOutroKindChange}
onChange={handleOutroChange}
/>
{/* ── 底部操作 ── */}
<div className="iop-footer">
@@ -2,65 +2,12 @@
* 混剪配置面板 — Drawer 形式
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
*/
import React, { useCallback, useMemo } from "react"
import React from "react"
import { Drawer, Switch } from "antd"
import type { PipConfig, PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../types"
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types"
/* ──────────── 常量 ──────────── */
/** 九宫格位置 → 百分比坐标映射 */
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
top_left: { x: 5, y: 5 },
top_center: { x: 37.5, y: 5 },
top_right: { x: 70, y: 5 },
center_left: { x: 5, y: 37.5 },
center: { x: 37.5, y: 37.5 },
center_right: { x: 70, y: 37.5 },
bottom_left: { x: 5, y: 70 },
bottom_center: { x: 37.5, y: 70 },
bottom_right: { x: 70, y: 70 },
}
/** 九宫格位置选项 */
const GRID_POSITIONS: PipGridPosition[] = [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
]
/** 入场动画选项 */
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
{ value: "none", label: "无" },
{ value: "fade_in", label: "淡入" },
{ value: "slide_in", label: "滑入" },
]
/** 滑入方向选项 */
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
{ value: "left", label: "← 左" },
{ value: "right", label: "→ 右" },
{ value: "up", label: "↑ 上" },
{ value: "down", label: "↓ 下" },
]
/** 预览图层颜色池 */
const LAYER_COLORS = [
"rgba(22,119,255,0.5)",
"rgba(82,196,26,0.5)",
"rgba(250,173,20,0.5)",
"rgba(255,77,79,0.5)",
"rgba(114,46,209,0.5)",
"rgba(19,194,194,0.5)",
]
/* ──────────── Props ──────────── */
import type { PipConfig } from "@/pages/editing-planner/types"
import LayerList from "./pip-config/LayerList"
import LayerConfig from "./pip-config/LayerConfig"
import { usePipLayers } from "@/pages/editing-planner/hooks/usePipLayers"
interface PipConfigPanelProps {
open: boolean
@@ -70,13 +17,6 @@ interface PipConfigPanelProps {
totalDuration: number
}
/* ──────────── 辅助函数 ──────────── */
let layerIdCounter = 0
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
/* ──────────── 组件 ──────────── */
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
open,
onClose,
@@ -84,106 +24,19 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
onChange,
totalDuration,
}) => {
/** 当前选中图层 ID */
const [selectedId, setSelectedId] = React.useState<string>("")
/** 当前选中图层 */
const selectedLayer = useMemo(
() => config.layers.find((l) => l.id === selectedId) ?? null,
[config.layers, selectedId],
)
/* ── 添加图层 ── */
const handleAddLayer = useCallback(() => {
const newLayer: PipLayer = {
...DEFAULT_PIP_LAYER,
id: genLayerId(),
name: `图层 ${config.layers.length + 1}`,
z_index: config.layers.length + 1,
}
onChange({
...config,
layers: [...config.layers, newLayer],
})
setSelectedId(newLayer.id)
}, [config, onChange])
/* ── 删除图层 ── */
const handleDeleteLayer = useCallback(
(id: string) => {
const newLayers = config.layers.filter((l) => l.id !== id)
onChange({ ...config, layers: newLayers })
if (selectedId === id) {
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
}
},
[config, onChange, selectedId],
)
/* ── 更新图层 ── */
const updateLayer = useCallback(
(id: string, partial: Partial<PipLayer>) => {
onChange({
...config,
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
})
},
[config, onChange],
)
/* ── 切换启用 ── */
const handleEnableToggle = useCallback(
(checked: boolean) => {
onChange({ ...config, enabled: checked })
},
[config, onChange],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_PIP_CONFIG })
setSelectedId("")
}, [onChange])
/* ── 九宫格点击 ── */
const handleGridClick = useCallback(
(pos: PipGridPosition) => {
if (!selectedLayer) return
const coords = GRID_POSITION_MAP[pos]
updateLayer(selectedLayer.id, {
grid_position: pos,
x: coords.x,
y: coords.y,
})
},
[selectedLayer, updateLayer],
)
/* ── 宽高比锁定 ── */
const handleWidthChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { width: val }
if (selectedLayer.aspect_lock) {
// 保持宽高比 1:1(百分比相同)
partial.height = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
const handleHeightChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { height: val }
if (selectedLayer.aspect_lock) {
partial.width = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
const {
selectedId,
setSelectedId,
selectedLayer,
handleAddLayer,
handleDeleteLayer,
updateLayer,
handleEnableToggle,
handleReset,
handleGridClick,
handleWidthChange,
handleHeightChange,
} = usePipLayers({ config, onChange })
return (
<Drawer
@@ -197,9 +50,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
{/* ═══ 顶部工具栏 ═══ */}
<div className="pip-toolbar">
<div className="pip-toolbar-left">
<button className="pip-add-btn" onClick={handleAddLayer}>
+
</button>
<span style={{ fontSize: 13, color: "#666" }}> {config.layers.length} </span>
</div>
<div className="pip-enable-switch">
<span></span>
@@ -209,329 +60,22 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
<div className="pip-body">
{/* 左侧图层列表 */}
<div className="pip-layer-list">
{config.layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => setSelectedId(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
handleDeleteLayer(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
{/* 右侧配置区 */}
<div className="pip-config-area">
{!selectedLayer ? (
<div className="pip-config-empty"></div>
) : (
<>
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
style={{
left: `${layer.x}%`,
top: `${layer.y}%`,
width: `${layer.width}%`,
height: `${layer.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: layer.opacity / 100,
borderRadius: `${layer.border_radius}%`,
}}
>
<span className="pip-preview-label">{layer.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
onClick={() => updateLayer(selectedLayer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
onClick={() => updateLayer(selectedLayer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{selectedLayer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
selectedLayer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={selectedLayer.material_url}
onChange={(e) =>
updateLayer(selectedLayer.id, {
material_url: e.target.value,
})
}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
onClick={() => handleGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.x}
onChange={(e) =>
updateLayer(selectedLayer.id, {
x: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.y}
onChange={(e) =>
updateLayer(selectedLayer.id, {
y: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{selectedLayer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{selectedLayer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() =>
updateLayer(selectedLayer.id, {
aspect_lock: !selectedLayer.aspect_lock,
})
}
>
<span className="pip-lock-icon">{selectedLayer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={selectedLayer.border_radius}
onChange={(e) =>
updateLayer(selectedLayer.id, {
border_radius: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">{selectedLayer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={selectedLayer.opacity}
onChange={(e) =>
updateLayer(selectedLayer.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">{selectedLayer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.start_time}
onChange={(e) =>
updateLayer(selectedLayer.id, {
start_time: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.duration}
onChange={(e) =>
updateLayer(selectedLayer.id, {
duration: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.animation}
onChange={(e) =>
updateLayer(selectedLayer.id, {
animation: e.target.value as PipAnimType,
})
}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{selectedLayer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.slide_direction}
onChange={(e) =>
updateLayer(selectedLayer.id, {
slide_direction: e.target.value as PipSlideDirection,
})
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
<LayerList
layers={config.layers}
selectedId={selectedId}
onSelect={setSelectedId}
onAdd={handleAddLayer}
onDelete={handleDeleteLayer}
/>
<LayerConfig
layer={selectedLayer}
layers={config.layers}
totalDuration={totalDuration}
onUpdate={updateLayer}
onGridClick={handleGridClick}
onWidthChange={handleWidthChange}
onHeightChange={handleHeightChange}
/>
</div>
{/* ── 底部操作 ── */}
@@ -2,10 +2,13 @@
* 贴纸配置面板
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
*/
import React, { useCallback, useState } from "react"
import React from "react"
import { Drawer, Switch } from "antd"
import type { StickerConfig, StickerItem, StickerType, TextStickerPreset } from "../types"
import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM, TEXT_STICKER_PRESET_LABELS } from "../types"
import type { StickerConfig } from "@/pages/editing-planner/types"
import StickerLibrary from "./sticker/StickerLibrary"
import StickerList from "./sticker/StickerList"
import StickerPropsEditor from "./sticker/StickerPropsEditor"
import { useStickerItems } from "@/pages/editing-planner/hooks/useStickerItems"
interface StickerPanelProps {
open: boolean
@@ -15,59 +18,6 @@ interface StickerPanelProps {
totalDuration: number
}
/** 常用 emoji 素材 */
const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
]
/** 文字花字预设对应的 CSS 样式预览 */
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
normal: { color: "#fff", textShadow: "none" },
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
gradient: {
color: "transparent",
background: "linear-gradient(90deg,#f093fb,#f5576c)",
WebkitBackgroundClip: "text",
},
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
}
/** 生成唯一 ID */
const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const StickerPanel: React.FC<StickerPanelProps> = ({
open,
onClose,
@@ -75,63 +25,17 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
onChange,
totalDuration,
}) => {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null
/** 更新单个贴纸 */
const updateItem = useCallback(
(id: string, partial: Partial<StickerItem>) => {
onChange({
...config,
items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)),
})
},
[config, onChange],
)
/** 添加贴纸 */
const addSticker = useCallback(
(type: StickerType, content: string) => {
const newItem: StickerItem = {
...DEFAULT_STICKER_ITEM,
id: genId(),
type,
content,
duration: totalDuration > 0 ? totalDuration : 5,
z_index: config.items.length + 1,
}
onChange({
...config,
enabled: true,
items: [...config.items, newItem],
})
setSelectedId(newItem.id)
},
[config, onChange, totalDuration],
)
/** 删除贴纸 */
const removeSticker = useCallback(
(id: string) => {
onChange({
...config,
items: config.items.filter((s) => s.id !== id),
})
if (selectedId === id) setSelectedId(null)
},
[config, onChange, selectedId],
)
/** 重置所有 */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled })
setSelectedId(null)
}, [config.enabled, onChange])
/** 文字花字输入 */
const [textInput, setTextInput] = useState("")
const {
selectedId,
setSelectedId,
activeTab,
setActiveTab,
selectedSticker,
updateItem,
addSticker,
removeSticker,
handleReset,
} = useStickerItems({ config, onChange, totalDuration })
return (
<Drawer
@@ -152,355 +56,24 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
/>
</div>
{/* 类型 Tab */}
<div className="sticker-tabs">
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
<button
key={t}
className={`sticker-tab${activeTab === t ? " active" : ""}`}
onClick={() => setActiveTab(t)}
>
{t === "emoji" ? "表情贴纸" : t === "image" ? "图片贴纸" : "文字花字"}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => addSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
addSticker("image", e.currentTarget.value.trim())
e.currentTarget.value = ""
}
}}
/>
<button
className="sticker-url-add-btn"
onClick={() => {
const input = document.querySelector<HTMLInputElement>(".sticker-url-input")
if (input?.value.trim()) {
addSticker("image", input.value.trim())
input.value = ""
}
}}
>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={() => {
if (textInput.trim()) {
addSticker("text", textInput.trim())
setTextInput("")
}
}}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
...TEXT_PRESET_STYLES[p],
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* 素材库 + 类型Tab */}
<StickerLibrary activeTab={activeTab} onTabChange={setActiveTab} onAddSticker={addSticker} />
{/* 已添加贴纸列表 */}
{config.items.length > 0 && (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({config.items.length})</div>
<div className="sticker-list">
{config.items.map((item) => (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className="sticker-list-icon">
{item.type === "emoji" ? item.content : item.type === "text" ? "T" : "🖼"}
</span>
<span className="sticker-list-name">
{item.type === "text"
? item.content.slice(0, 10)
: item.type === "emoji"
? "表情贴纸"
: "图片贴纸"}
</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
removeSticker(item.id)
}}
>
</button>
</div>
))}
</div>
</div>
)}
<StickerList
items={config.items}
selectedId={selectedId}
onSelect={setSelectedId}
onDelete={removeSticker}
/>
{/* 选中贴纸的属性编辑 */}
{selectedSticker && (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.x}
onChange={(e) => updateItem(selectedSticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{selectedSticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.y}
onChange={(e) => updateItem(selectedSticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{selectedSticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={selectedSticker.width}
onChange={(e) =>
updateItem(selectedSticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={selectedSticker.rotation}
onChange={(e) =>
updateItem(selectedSticker.id, {
rotation: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.opacity}
onChange={(e) =>
updateItem(selectedSticker.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.start_time}
onChange={(e) =>
updateItem(selectedSticker.id, {
start_time: Number(e.target.value),
})
}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.duration}
onChange={(e) =>
updateItem(selectedSticker.id, {
duration: Number(e.target.value),
})
}
/>
</div>
{/* 文字贴纸特有属性 */}
{selectedSticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={selectedSticker.text_preset}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_preset: e.target.value as TextStickerPreset,
})
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={selectedSticker.font_size}
onChange={(e) =>
updateItem(selectedSticker.id, {
font_size: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={selectedSticker.text_color}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_color: e.target.value,
})
}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${selectedSticker.x}%`,
top: `${selectedSticker.y}%`,
width: `${selectedSticker.width}%`,
height: `${selectedSticker.width}%`,
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
opacity: selectedSticker.opacity / 100,
fontSize:
selectedSticker.type === "text" ? `${selectedSticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
}}
>
{selectedSticker.type === "emoji" && selectedSticker.content}
{selectedSticker.type === "text" && selectedSticker.content}
{selectedSticker.type === "image" && (
<img
src={selectedSticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
<StickerPropsEditor
sticker={selectedSticker}
totalDuration={totalDuration}
onUpdate={updateItem}
/>
)}
{/* 底部重置 */}
@@ -5,31 +5,14 @@
import React from "react"
import { Drawer, Slider, ColorPicker, Select } from "antd"
import type { Color } from "antd/es/color-picker"
import type { SubtitleStyleConfig } from "../types/subtitle"
/* ──────────── 选项常量 ──────────── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
const ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]
/* ──────────── Props ──────────── */
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
ANIMATION_OPTIONS,
ASR_LANGUAGE_OPTIONS,
} from "@/pages/editing-planner/constants/subtitleStyle"
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
interface SubtitleStylePanelProps {
open: boolean
@@ -197,23 +180,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
{/* ── 预览 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
}}
>
</span>
</div>
</div>
<SubtitlePreview config={config} />
</>
)}
</Drawer>
+62 -220
View File
@@ -2,23 +2,14 @@
* TTS 配音面板 — Drawer 形式
* 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动
*/
import React, { useState, useCallback, useEffect, useRef } from "react"
import { Drawer, Slider, message } from "antd"
import type { TtsConfig, TtsMode } from "../types"
import { DEFAULT_TTS_CONFIG } from "../types"
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
import React from "react"
import { Drawer } from "antd"
import type { TtsConfig } from "@/pages/editing-planner/types"
import { TTS_MODE_OPTIONS } from "@/pages/editing-planner/constants/tts"
import VoiceSelector from "./tts/VoiceSelector"
import TtsSlider from "./tts/TtsSlider"
import { useTtsPanel } from "@/pages/editing-planner/hooks/useTtsPanel"
/* ──────────── 音色卡片分类图标 ──────────── */
const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
}
/* ──────────── Props ──────────── */
interface TtsPanelProps {
open: boolean
onClose: () => void
@@ -28,126 +19,21 @@ interface TtsPanelProps {
}
const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange }) => {
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── 加载音色列表 ── */
useEffect(() => {
if (!open) return
setVoicesLoading(true)
getTtsVoices()
.then((v) => setVoices(v))
.catch(() => message.error("加载音色列表失败"))
.finally(() => setVoicesLoading(false))
}, [open])
/* ── 切换配音模式 ── */
const handleModeChange = useCallback(
(mode: TtsMode) => {
onChange({ ...config, mode })
},
[config, onChange],
)
/* ── 文本输入 ── */
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value.slice(0, 5000)
onChange({ ...config, text })
},
[config, onChange],
)
/* ── 选择音色 ── */
const handleVoiceSelect = useCallback(
(voiceId: string) => {
onChange({ ...config, voice_id: voiceId })
},
[config, onChange],
)
/* ── 语速 ── */
const handleSpeedChange = useCallback(
(speed: number) => {
onChange({ ...config, speed })
},
[config, onChange],
)
/* ── 语调 ── */
const handlePitchChange = useCallback(
(pitch: number) => {
onChange({ ...config, pitch })
},
[config, onChange],
)
/* ── 音量 ── */
const handleVolumeChange = useCallback(
(volume: number) => {
onChange({ ...config, volume })
},
[config, onChange],
)
/* ── 字幕联动 ── */
const handleSubtitleSyncToggle = useCallback(() => {
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
}, [config, onChange])
/* ── 试听 ── */
const handlePreview = useCallback(async () => {
if (!config.text.trim()) {
message.warning("请先输入合成文本")
return
}
if (!config.voice_id) {
message.warning("请先选择音色")
return
}
setPreviewLoading(true)
try {
const res = await previewTts({
text: config.text.slice(0, 200), // 试听截取前200字
voice_id: config.voice_id,
speed: config.speed,
pitch: config.pitch,
})
// 停止上一个
audioRef.current?.pause()
const audio = new Audio(res.audio_url)
audioRef.current = audio
audio.play().catch(() => message.error("播放失败"))
audio.onended = () => {
audioRef.current = null
}
message.success("试听播放中")
} catch {
message.error("试听生成失败")
} finally {
setPreviewLoading(false)
}
}, [config])
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG })
}, [onChange])
/* ── 关闭时停止音频 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause()
audioRef.current = null
onClose()
}, [onClose])
/* ── 音色分类分组 ── */
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
const {
voices,
voicesLoading,
previewLoading,
handleModeChange,
handleTextChange,
handleVoiceSelect,
handleSpeedChange,
handlePitchChange,
handleVolumeChange,
handleSubtitleSyncToggle,
handlePreview,
handleReset,
handleClose,
} = useTtsPanel({ open, config, onChange, onClose })
return (
<Drawer
@@ -162,11 +48,7 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
<div className="tts-mode-section">
<div className="tts-mode-label"></div>
<div className="tts-mode-group">
{[
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
].map((m) => (
{TTS_MODE_OPTIONS.map((m) => (
<button
key={m.mode}
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
@@ -192,97 +74,57 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
className="tts-text-input"
placeholder="请输入需要合成的文本内容..."
value={config.text}
onChange={handleTextChange}
onChange={(e) => handleTextChange(e.target.value)}
maxLength={5000}
rows={5}
/>
</div>
{/* 音色选择 */}
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && config.voice_id === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && handleVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
<VoiceSelector
voices={voices}
voicesLoading={voicesLoading}
selectedVoiceId={config.voice_id}
onVoiceSelect={handleVoiceSelect}
/>
{/* 语速滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.speed.toFixed(2)}x</span>
</div>
<Slider
min={0.5}
max={2.0}
step={0.05}
value={config.speed}
onChange={handleSpeedChange}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="tts-slider-marks">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
<TtsSlider
label="语速"
value={config.speed}
min={0.5}
max={2.0}
step={0.05}
unit="x"
onChange={handleSpeedChange}
marks={["0.5x", "1.0x", "2.0x"]}
tooltipFormatter={(v) => `${v.toFixed(2)}x`}
/>
{/* 语调滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">
{config.pitch > 0 ? "+" : ""}
{config.pitch}
</span>
</div>
<Slider
min={-12}
max={12}
step={1}
value={config.pitch}
onChange={handlePitchChange}
tooltip={{ formatter: (v) => `${v}半音` }}
/>
<div className="tts-slider-marks">
<span>-12</span>
<span>0</span>
<span>+12</span>
</div>
</div>
<TtsSlider
label="语调"
value={config.pitch}
min={-12}
max={12}
step={1}
unit="半音"
onChange={handlePitchChange}
marks={["-12", "0", "+12"]}
tooltipFormatter={(v) => `${v}半音`}
/>
{/* 音量滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.volume}%</span>
</div>
<Slider
min={0}
max={100}
step={1}
value={config.volume}
onChange={handleVolumeChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
</div>
<TtsSlider
label="音量"
value={config.volume}
min={0}
max={100}
step={1}
unit="%"
onChange={handleVolumeChange}
tooltipFormatter={(v) => `${v}%`}
/>
{/* 试听按钮 */}
<div className="tts-preview-section">
@@ -3,38 +3,16 @@
* 三个 Tab:图片水印 / 文字水印 / 滚动水印
* 通用设置:位置、不透明度
*/
import React, { useCallback, useState } from "react"
import React from "react"
import { Drawer } from "antd"
import type { WatermarkConfig, WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
import { DEFAULT_WATERMARK } from "../types"
import type { WatermarkConfig } from "@/pages/editing-planner/types"
import WatermarkTypeTabs from "./watermark/WatermarkTypeTabs"
import ImageWatermarkSection from "./watermark/ImageWatermarkSection"
import TextWatermarkSection from "./watermark/TextWatermarkSection"
import ScrollWatermarkSection from "./watermark/ScrollWatermarkSection"
import WatermarkCommonSection from "./watermark/WatermarkCommonSection"
import { useWatermarkConfig } from "@/pages/editing-planner/hooks/useWatermarkConfig"
/* ──────────── 常量 ──────────── */
const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
{ key: "none", label: "无水印", icon: "🚫" },
{ key: "image", label: "图片水印", icon: "🖼️" },
{ key: "text", label: "文字水印", icon: "📝" },
{ key: "scroll", label: "滚动水印", icon: "📜" },
]
const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
{ value: "top_left", label: "左上角" },
{ value: "top_right", label: "右上角" },
{ value: "bottom_left", label: "左下角" },
{ value: "bottom_right", label: "右下角" },
{ value: "center", label: "居中" },
]
const SCROLL_DIRECTION_OPTIONS: {
value: ScrollDirection
label: string
}[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
]
/* ──────────── Props ──────────── */
interface WatermarkPanelProps {
open: boolean
onClose: () => void
@@ -43,100 +21,21 @@ interface WatermarkPanelProps {
}
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config, onChange }) => {
/* ── 图片上传预览 URL(本地预览用) ── */
const [localImageUrl, setLocalImageUrl] = useState<string>("")
/* ── 切换水印类型 ── */
const handleTypeChange = useCallback(
(type: WatermarkType) => {
onChange({ ...DEFAULT_WATERMARK, type })
},
[onChange],
)
/* ── 通用设置变更 ── */
const handlePositionChange = useCallback(
(position: WatermarkPosition) => {
onChange({ ...config, position })
},
[config, onChange],
)
const handleOpacityChange = useCallback(
(opacity: number) => {
onChange({ ...config, opacity })
},
[config, onChange],
)
/* ── 图片水印设置 ── */
const handleImageUrlChange = useCallback(
(url: string) => {
setLocalImageUrl(url)
onChange({ ...config, image_url: url })
},
[config, onChange],
)
const handleImageWidthChange = useCallback(
(width: number) => {
onChange({ ...config, width })
},
[config, onChange],
)
const handleImageHeightChange = useCallback(
(height: number) => {
onChange({ ...config, height })
},
[config, onChange],
)
/* ── 文字水印设置 ── */
const handleTextChange = useCallback(
(text: string) => {
onChange({ ...config, text })
},
[config, onChange],
)
const handleFontSizeChange = useCallback(
(font_size: number) => {
onChange({ ...config, font_size })
},
[config, onChange],
)
const handleColorChange = useCallback(
(color: string) => {
onChange({ ...config, color })
},
[config, onChange],
)
/* ── 滚动水印设置 ── */
const handleScrollDirectionChange = useCallback(
(scroll_direction: ScrollDirection) => {
onChange({ ...config, scroll_direction })
},
[config, onChange],
)
const handleScrollSpeedChange = useCallback(
(scroll_speed: number) => {
onChange({ ...config, scroll_speed })
},
[config, onChange],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
setLocalImageUrl("")
onChange({ ...DEFAULT_WATERMARK })
}, [onChange])
/* ── 当前激活的 Tab ── */
const activeTab = config.type
const {
localImageUrl,
handleTypeChange,
handlePositionChange,
handleOpacityChange,
handleImageUrlChange,
handleImageWidthChange,
handleImageHeightChange,
handleTextChange,
handleFontSizeChange,
handleColorChange,
handleScrollDirectionChange,
handleScrollSpeedChange,
handleReset,
} = useWatermarkConfig({ config, onChange })
return (
<Drawer
@@ -148,18 +47,7 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
className="watermark-panel-drawer"
>
{/* ── Tab 切换 ── */}
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => handleTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
<WatermarkTypeTabs activeTab={config.type} onTypeChange={handleTypeChange} />
{/* ── 无水印提示 ── */}
{config.type === "none" && (
@@ -172,210 +60,53 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
{/* ── 图片水印配置 ── */}
{config.type === "image" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={localImageUrl || config.image_url || ""}
onChange={(e) => handleImageUrlChange(e.target.value)}
/>
{(localImageUrl || config.image_url) && (
<div className="wp-image-preview">
<img
src={localImageUrl || config.image_url}
alt="水印预览"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.width ?? 0}
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.height ?? 0}
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
/>
</div>
</div>
<ImageWatermarkSection
imageUrl={config.image_url || ""}
localImageUrl={localImageUrl}
width={config.width}
height={config.height}
onImageUrlChange={handleImageUrlChange}
onWidthChange={handleImageWidthChange}
onHeightChange={handleImageHeightChange}
/>
)}
{/* ── 文字水印配置 ── */}
{config.type === "text" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.font_size ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">{config.color ?? "#ffffff"}</span>
</div>
</div>
</div>
<TextWatermarkSection
text={config.text || ""}
fontSize={config.font_size ?? 24}
color={config.color ?? "#ffffff"}
onTextChange={handleTextChange}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
/>
)}
{/* ── 滚动水印配置 ── */}
{config.type === "scroll" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.scroll_direction ?? "horizontal"}
onChange={(e) => handleScrollDirectionChange(e.target.value as ScrollDirection)}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={config.scroll_speed ?? 50}
onChange={(e) => handleScrollSpeedChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.scroll_speed ?? 50}px/s</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.font_size ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">{config.color ?? "#ffffff"}</span>
</div>
</div>
</div>
<ScrollWatermarkSection
text={config.text || ""}
scrollDirection={config.scroll_direction ?? "horizontal"}
scrollSpeed={config.scroll_speed ?? 50}
fontSize={config.font_size ?? 24}
color={config.color ?? "#ffffff"}
onTextChange={handleTextChange}
onScrollDirectionChange={handleScrollDirectionChange}
onScrollSpeedChange={handleScrollSpeedChange}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
/>
)}
{/* ── 通用设置(非 none 时显示) ── */}
{config.type !== "none" && (
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.position}
onChange={(e) => handlePositionChange(e.target.value as WatermarkPosition)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => handleOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{Math.round(config.opacity * 100)}%</span>
</div>
</div>
</div>
<WatermarkCommonSection
position={config.position}
opacity={config.opacity}
onPositionChange={handlePositionChange}
onOpacityChange={handleOpacityChange}
/>
)}
{/* ── 底部操作 ── */}
@@ -0,0 +1,41 @@
/**
* BGM 设置区块
*/
import React from "react"
import type { BgmSettings } from "@/pages/editing-planner/types/clipProperties"
interface BgmSettingsSectionProps {
settings: BgmSettings
onOpenBgmDrawer?: () => void
}
const BgmSettingsSection: React.FC<BgmSettingsSectionProps> = ({ settings, onOpenBgmDrawer }) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎵</span>
BGM
</div>
{settings.enabled && settings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{settings.music_id}</span>
{settings.volume !== undefined && (
<span className="ep-bgm-current-vol"> {settings.volume}%</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {settings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
)
}
export default BgmSettingsSection
@@ -0,0 +1,266 @@
/**
* 片段详情区块
*/
import React from "react"
import { useNavigate } from "react-router-dom"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
import type { AssetItem } from "@/api/assets"
import type { TemplateMode } from "@/api/editing-planner"
interface ClipDetailSectionProps {
clip: ClipData
currentMode: TemplateMode
voiceMaterials?: AssetItem[]
voiceMaterialsLoading?: boolean
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
onRefreshVoiceMaterials?: () => void
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer?: (clipId: string) => void
onOpenSpeedDrawer?: (clipId: string) => void
onOpenTtsDrawer?: (clipId: string) => void
previewingId: string | null
onPreviewVoice: (asset: AssetItem) => void
onStopPreview: () => void
}
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
clip,
currentMode,
voiceMaterials = [],
voiceMaterialsLoading = false,
onClipUpdate,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
previewingId,
onPreviewVoice,
onStopPreview,
}) => {
const navigate = useNavigate()
const isTypeDisabled = (t: ClipType) => {
if (currentMode === "pip") return t !== "pip"
if (currentMode === "voice_over") return t !== "voice"
return false
}
const transitionLabel = (() => {
const t = clip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
})()
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
const ttsLabel = (() => {
const tts = clip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled = isTypeDisabled(t)
return (
<button
key={t}
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={clip.duration}
onChange={(e) =>
onClipUpdate(clip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{transitionLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{speedLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">{ttsLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={clip.startOffset}
onChange={(e) =>
onClipUpdate(clip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={clip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(clip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(clip.id, asset)
}
onStopPreview()
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{clip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
if (asset) onPreviewVoice(asset)
}}
>
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)
}
export default ClipDetailSection
@@ -0,0 +1,39 @@
/**
* 编辑统计区块
*/
import React from "react"
import { formatModeLabel } from "@/pages/editing-planner/utils/clipProperties"
import type { TemplateMode } from "@/api/editing-planner"
interface StatsSectionProps {
clipsCount: number
totalDuration: number
currentMode: TemplateMode
}
const StatsSection: React.FC<StatsSectionProps> = ({ clipsCount, totalDuration, currentMode }) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📊</span>
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{clipsCount}</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{formatModeLabel(currentMode)}</div>
</div>
</div>
</div>
)
}
export default StatsSection
@@ -0,0 +1,113 @@
/**
* 字幕设置区块
*/
import React from "react"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
ANIMATION_OPTIONS,
} from "@/pages/editing-planner/constants/clipProperties"
import type { SubtitleSettings } from "@/pages/editing-planner/types/clipProperties"
interface SubtitleSettingsSectionProps {
settings: SubtitleSettings
onChange: (partial: Partial<SubtitleSettings>) => void
onOpenSubtitleDrawer?: () => void
}
const SubtitleSettingsSection: React.FC<SubtitleSettingsSectionProps> = ({
settings,
onChange,
onOpenSubtitleDrawer,
}) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">💬</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label"></span>
<div
className={`ep-toggle ${settings.enabled ? "active" : ""}`}
onClick={() => onChange({ enabled: !settings.enabled })}
>
<div className="ep-toggle-knob" />
</div>
</div>
{settings.enabled && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.position}
onChange={(e) => onChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.font}
onChange={(e) => onChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={settings.fontSize}
onChange={(e) => onChange({ fontSize: Number(e.target.value) })}
/>
<span className="ep-slider-value">{settings.fontSize}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.animation}
onChange={(e) => onChange({ animation: e.target.value })}
>
{ANIMATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{onOpenSubtitleDrawer && (
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
🎨
</button>
)}
</>
)}
</div>
)
}
export default SubtitleSettingsSection
@@ -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
@@ -0,0 +1,40 @@
/**
* 滤镜手动调节组件
*/
import React from "react"
import type { FilterConfig } from "../../types"
import { MANUAL_ADJUST_ITEMS } from "../../constants/filter"
type FilterKey = keyof Pick<
FilterConfig,
"brightness" | "contrast" | "saturation" | "temperature" | "tint" | "sharpness"
>
interface FilterManualAdjustProps {
config: FilterConfig
onChange: (key: FilterKey, value: number) => void
}
const FilterManualAdjust: React.FC<FilterManualAdjustProps> = ({ config, onChange }) => {
return (
<div className="filter-section">
<div className="filter-section-title"></div>
{MANUAL_ADJUST_ITEMS.map((item) => (
<div key={item.key} className="filter-slider-row">
<span className="filter-slider-label">{item.label}</span>
<input
type="range"
className="filter-slider"
min={item.min}
max={item.max}
value={config[item.key as FilterKey]}
onChange={(e) => onChange(item.key as FilterKey, Number(e.target.value))}
/>
<span className="filter-slider-value">{config[item.key as FilterKey]}</span>
</div>
))}
</div>
)
}
export default FilterManualAdjust
@@ -0,0 +1,34 @@
/**
* 滤镜预设选择组件
*/
import React from "react"
import type { FilterPreset } from "../../types"
import { FILTER_PRESET_LABELS } from "../../types"
import { PRESET_LIST, PRESET_GRADIENTS } from "../../constants/filter"
interface FilterPresetGridProps {
selectedPreset: FilterPreset
onPresetSelect: (preset: FilterPreset) => void
}
const FilterPresetGrid: React.FC<FilterPresetGridProps> = ({ selectedPreset, onPresetSelect }) => {
return (
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${selectedPreset === p ? " active" : ""}`}
onClick={() => onPresetSelect(p)}
>
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
</button>
))}
</div>
</div>
)
}
export default FilterPresetGrid
@@ -0,0 +1,123 @@
/**
* 片头/片尾通用区块组件(片头片尾结构对称,复用同一个组件)
*/
import React from "react"
import type { IntroOutroItem, IntroOutroKind, TransitionType } from "../../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import { KIND_OPTIONS } from "../../constants/introOutro"
interface IntroOutroBlockProps {
title: string
icon: string
item: IntroOutroItem
transitionLabel: string
onKindChange: (kind: IntroOutroKind) => void
onChange: (partial: Partial<IntroOutroItem>) => void
}
const IntroOutroBlock: React.FC<IntroOutroBlockProps> = ({
title,
icon,
item,
transitionLabel,
onKindChange,
onChange,
}) => {
const hasContent = item.kind !== "none"
return (
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">{icon}</span>
<span className="iop-block-title">{title}</span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${item.kind === opt.value ? " active" : ""}`}
onClick={() => onKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{hasContent && (
<>
<div className="iop-field">
<label className="iop-field-label">{item.kind === "video" ? "视频" : "图片"} URL</label>
<input
className="iop-input"
type="text"
placeholder={
item.kind === "video"
? `https://example.com/${title}.mp4`
: `https://example.com/${title}.png`
}
value={item.url ?? ""}
onChange={(e) => onChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={item.duration}
onChange={(e) => onChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{item.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">{transitionLabel}</label>
<select
className="iop-select"
value={item.transition ?? "none"}
onChange={(e) => onChange({ transition: e.target.value as TransitionType })}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{item.transition && item.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={item.transition_duration ?? 0.5}
onChange={(e) => onChange({ transition_duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">
{(item.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
)
}
export default IntroOutroBlock
@@ -0,0 +1,286 @@
/**
* 混剪单图层配置区
*/
import React from "react"
import type {
PipLayer,
PipAnimType,
PipSlideDirection,
PipGridPosition,
} from "@/pages/editing-planner/types"
import {
GRID_POSITIONS,
ANIM_OPTIONS,
SLIDE_DIR_OPTIONS,
LAYER_COLORS,
} from "@/pages/editing-planner/constants/pipConfig"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</div>
)
}
export default LayerConfig
@@ -0,0 +1,65 @@
/**
* 混剪图层列表
*/
import React from "react"
import type { PipLayer } from "@/pages/editing-planner/types"
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerListProps {
layers: PipLayer[]
selectedId: string
onSelect: (id: string) => void
onAdd: () => void
onDelete: (id: string) => void
}
const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onAdd, onDelete }) => {
return (
<div className="pip-layer-list">
<div className="pip-toolbar" style={{ marginBottom: 8 }}>
<button className="pip-add-btn" onClick={onAdd}>
+
</button>
</div>
{layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => onSelect(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
)
}
export default LayerList
@@ -0,0 +1,142 @@
/**
* 贴纸素材库(emoji / 图片 / 文字花字)
*/
import React, { useState } from "react"
import type { StickerType, TextStickerPreset } from "@/pages/editing-planner/types"
import {
EMOJI_LIST,
STICKER_TYPE_TABS,
TEXT_PRESET_STYLES,
TEXT_STICKER_PRESET_LABELS,
} from "@/pages/editing-planner/constants/sticker"
interface StickerLibraryProps {
activeTab: StickerType
onTabChange: (tab: StickerType) => void
onAddSticker: (type: StickerType, content: string) => void
}
const StickerLibrary: React.FC<StickerLibraryProps> = ({
activeTab,
onTabChange,
onAddSticker,
}) => {
const [textInput, setTextInput] = useState("")
const imageInputRef = React.useRef<HTMLInputElement>(null)
const handleAddImage = () => {
const val = imageInputRef.current?.value.trim()
if (val) {
onAddSticker("image", val)
if (imageInputRef.current) imageInputRef.current.value = ""
}
}
const handleAddText = () => {
if (textInput.trim()) {
onAddSticker("text", textInput.trim())
setTextInput("")
}
}
return (
<>
{/* 类型 Tab */}
<div className="sticker-tabs">
{STICKER_TYPE_TABS.map((t) => (
<button
key={t.value}
className={`sticker-tab${activeTab === t.value ? " active" : ""}`}
onClick={() => onTabChange(t.value)}
>
{t.label}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => onAddSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
ref={imageInputRef}
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
handleAddImage()
}
}}
/>
<button className="sticker-url-add-btn" onClick={handleAddImage}>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={handleAddText}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
)
}
export default StickerLibrary
@@ -0,0 +1,54 @@
/**
* 已添加贴纸列表
*/
import React from "react"
import type { StickerItem } from "@/pages/editing-planner/types"
interface StickerListProps {
items: StickerItem[]
selectedId: string | null
onSelect: (id: string) => void
onDelete: (id: string) => void
}
const StickerList: React.FC<StickerListProps> = ({ items, selectedId, onSelect, onDelete }) => {
if (items.length === 0) return null
const getDisplayContent = (item: StickerItem) => {
if (item.type === "emoji") return { icon: item.content, name: "表情贴纸" }
if (item.type === "text") return { icon: "T", name: item.content.slice(0, 10) }
return { icon: "🖼", name: "图片贴纸" }
}
return (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({items.length})</div>
<div className="sticker-list">
{items.map((item) => {
const { icon, name } = getDisplayContent(item)
return (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="sticker-list-icon">{icon}</span>
<span className="sticker-list-name">{name}</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(item.id)
}}
>
</button>
</div>
)
})}
</div>
</div>
)
}
export default StickerList
@@ -0,0 +1,200 @@
/**
* 选中贴纸的属性编辑器
*/
import React from "react"
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
import {
TEXT_PRESET_STYLES,
TEXT_STICKER_PRESET_LABELS,
} from "@/pages/editing-planner/constants/sticker"
interface StickerPropsEditorProps {
sticker: StickerItem
totalDuration: number
onUpdate: (id: string, partial: Partial<StickerItem>) => void
}
const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
sticker,
totalDuration,
onUpdate,
}) => {
return (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.x}
onChange={(e) => onUpdate(sticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.y}
onChange={(e) => onUpdate(sticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={sticker.width}
onChange={(e) =>
onUpdate(sticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{sticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={sticker.rotation}
onChange={(e) => onUpdate(sticker.id, { rotation: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.opacity}
onChange={(e) => onUpdate(sticker.id, { opacity: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.start_time}
onChange={(e) => onUpdate(sticker.id, { start_time: Number(e.target.value) })}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.duration}
onChange={(e) => onUpdate(sticker.id, { duration: Number(e.target.value) })}
/>
</div>
{/* 文字贴纸特有属性 */}
{sticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={sticker.text_preset}
onChange={(e) =>
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={sticker.font_size}
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={sticker.text_color}
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
width: `${sticker.width}%`,
height: `${sticker.width}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
opacity: sticker.opacity / 100,
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[sticker.text_preset],
}}
>
{sticker.type === "emoji" && sticker.content}
{sticker.type === "text" && sticker.content}
{sticker.type === "image" && (
<img
src={sticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
)
}
export default StickerPropsEditor
@@ -0,0 +1,37 @@
/**
* 字幕预览组件
*/
import React from "react"
import type { SubtitleStyleConfig } from "../../types/subtitle"
interface SubtitlePreviewProps {
config: SubtitleStyleConfig
previewText?: string
}
const SubtitlePreview: React.FC<SubtitlePreviewProps> = ({
config,
previewText = "这是一段字幕预览",
}) => {
return (
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
}}
>
{previewText}
</span>
</div>
</div>
)
}
export default SubtitlePreview
@@ -0,0 +1,62 @@
/**
* TTS 滑块组件(语速/语调/音量)
*/
import React from "react"
import { Slider } from "antd"
interface TtsSliderProps {
label: string
value: number
min: number
max: number
step: number
unit?: string
onChange: (val: number) => void
marks?: string[]
tooltipFormatter?: (v: number) => string
}
const TtsSlider: React.FC<TtsSliderProps> = ({
label,
value,
min,
max,
step,
unit = "",
onChange,
marks,
tooltipFormatter,
}) => {
const displayValue =
unit === "x"
? `${value.toFixed(2)}x`
: label === "语调"
? `${value > 0 ? "+" : ""}${value} 半音`
: `${value}${unit}`
return (
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label">{label}</span>
<span className="tts-slider-value">{displayValue}</span>
</div>
<Slider
min={min}
max={max}
step={step}
value={value}
onChange={(v) => onChange(v as number)}
tooltip={tooltipFormatter ? { formatter: (v) => tooltipFormatter(v as number) } : undefined}
/>
{marks && (
<div className="tts-slider-marks">
{marks.map((m, i) => (
<span key={i}>{m}</span>
))}
</div>
)}
</div>
)
}
export default TtsSlider
@@ -0,0 +1,50 @@
/**
* TTS 音色选择组件
*/
import React from "react"
import type { TTSVoice } from "@/api/tts"
import { VOICE_CATEGORY_MAP } from "../../constants/tts"
interface VoiceSelectorProps {
voices: TTSVoice[]
voicesLoading: boolean
selectedVoiceId: string
onVoiceSelect: (voiceId: string) => void
}
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
voices,
voicesLoading,
selectedVoiceId,
onVoiceSelect,
}) => {
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
return (
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && selectedVoiceId === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && onVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
)
}
export default VoiceSelector
@@ -0,0 +1,78 @@
/**
* 图片水印配置组件
*/
import React from "react"
interface ImageWatermarkSectionProps {
imageUrl: string
localImageUrl: string
width: number | undefined
height: number | undefined
onImageUrlChange: (url: string) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const ImageWatermarkSection: React.FC<ImageWatermarkSectionProps> = ({
imageUrl,
localImageUrl,
width,
height,
onImageUrlChange,
onWidthChange,
onHeightChange,
}) => {
const displayUrl = localImageUrl || imageUrl || ""
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={displayUrl}
onChange={(e) => onImageUrlChange(e.target.value)}
/>
{displayUrl && (
<div className="wp-image-preview">
<img
src={displayUrl}
alt="水印预览"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={width ?? 0}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={height ?? 0}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
</div>
</div>
)
}
export default ImageWatermarkSection
@@ -0,0 +1,107 @@
/**
* 滚动水印配置组件
*/
import React from "react"
import type { ScrollDirection } from "../../types"
import { SCROLL_DIRECTION_OPTIONS } from "../../constants/watermark"
interface ScrollWatermarkSectionProps {
text: string | undefined
scrollDirection: ScrollDirection | undefined
scrollSpeed: number | undefined
fontSize: number | undefined
color: string | undefined
onTextChange: (text: string) => void
onScrollDirectionChange: (dir: ScrollDirection) => void
onScrollSpeedChange: (speed: number) => void
onFontSizeChange: (size: number) => void
onColorChange: (color: string) => void
}
const ScrollWatermarkSection: React.FC<ScrollWatermarkSectionProps> = ({
text,
scrollDirection,
scrollSpeed,
fontSize,
color,
onTextChange,
onScrollDirectionChange,
onScrollSpeedChange,
onFontSizeChange,
onColorChange,
}) => {
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={text ?? ""}
onChange={(e) => onTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={scrollDirection ?? "horizontal"}
onChange={(e) => onScrollDirectionChange(e.target.value as ScrollDirection)}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={scrollSpeed ?? 50}
onChange={(e) => onScrollSpeedChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{scrollSpeed ?? 50}px/s</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={fontSize ?? 24}
onChange={(e) => onFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{fontSize ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={color ?? "#ffffff"}
onChange={(e) => onColorChange(e.target.value)}
/>
<span className="wp-color-value">{color ?? "#ffffff"}</span>
</div>
</div>
</div>
)
}
export default ScrollWatermarkSection
@@ -0,0 +1,67 @@
/**
* 文字水印配置组件
*/
import React from "react"
interface TextWatermarkSectionProps {
text: string | undefined
fontSize: number | undefined
color: string | undefined
onTextChange: (text: string) => void
onFontSizeChange: (size: number) => void
onColorChange: (color: string) => void
}
const TextWatermarkSection: React.FC<TextWatermarkSectionProps> = ({
text,
fontSize,
color,
onTextChange,
onFontSizeChange,
onColorChange,
}) => {
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={text ?? ""}
onChange={(e) => onTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={fontSize ?? 24}
onChange={(e) => onFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{fontSize ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={color ?? "#ffffff"}
onChange={(e) => onColorChange(e.target.value)}
/>
<span className="wp-color-value">{color ?? "#ffffff"}</span>
</div>
</div>
</div>
)
}
export default TextWatermarkSection
@@ -0,0 +1,60 @@
/**
* 水印通用设置组件(位置 + 透明度)
*/
import React from "react"
import type { WatermarkPosition } from "../../types"
import { POSITION_OPTIONS } from "../../constants/watermark"
interface WatermarkCommonSectionProps {
position: WatermarkPosition
opacity: number
onPositionChange: (pos: WatermarkPosition) => void
onOpacityChange: (opacity: number) => void
}
const WatermarkCommonSection: React.FC<WatermarkCommonSectionProps> = ({
position,
opacity,
onPositionChange,
onOpacityChange,
}) => {
return (
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={position}
onChange={(e) => onPositionChange(e.target.value as WatermarkPosition)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={opacity}
onChange={(e) => onOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{Math.round(opacity * 100)}%</span>
</div>
</div>
</div>
)
}
export default WatermarkCommonSection
@@ -0,0 +1,30 @@
/**
* 水印类型 Tab 组件
*/
import React from "react"
import type { WatermarkType } from "../../types"
import { WATERMARK_TABS } from "../../constants/watermark"
interface WatermarkTypeTabsProps {
activeTab: WatermarkType
onTypeChange: (type: WatermarkType) => void
}
const WatermarkTypeTabs: React.FC<WatermarkTypeTabsProps> = ({ activeTab, onTypeChange }) => {
return (
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => onTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
)
}
export default WatermarkTypeTabs
@@ -0,0 +1,34 @@
/**
* ClipPropertiesPanel 常量定义
*/
import type { ClipType } from "@/pages/editing-planner/types"
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
/** 片段类型图标/标签 */
export const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
@@ -0,0 +1,42 @@
/**
* FilterPanel 相关常量
*/
import type { FilterPreset } from "../types"
/** 所有预设列表 */
export const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
]
/** 预设对应的示例渐变色(用于视觉预览) */
export const PRESET_GRADIENTS: Record<FilterPreset, string> = {
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
}
/** 手动调节项配置 */
export const MANUAL_ADJUST_ITEMS = [
{ key: "brightness", label: "亮度", min: -100, max: 100 },
{ key: "contrast", label: "对比度", min: -100, max: 100 },
{ key: "saturation", label: "饱和度", min: -100, max: 100 },
{ key: "temperature", label: "色温", min: -100, max: 100 },
{ key: "tint", label: "色调", min: -100, max: 100 },
{ key: "sharpness", label: "锐度", min: 0, max: 100 },
] as const
@@ -0,0 +1,10 @@
/**
* IntroOutroPanel 相关常量
*/
import type { IntroOutroKind } from "../types"
export const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
]
@@ -0,0 +1,55 @@
/**
* PipConfigPanel 常量定义
*/
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
/** 九宫格位置 → 百分比坐标映射 */
export const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
top_left: { x: 5, y: 5 },
top_center: { x: 37.5, y: 5 },
top_right: { x: 70, y: 5 },
center_left: { x: 5, y: 37.5 },
center: { x: 37.5, y: 37.5 },
center_right: { x: 70, y: 37.5 },
bottom_left: { x: 5, y: 70 },
bottom_center: { x: 37.5, y: 70 },
bottom_right: { x: 70, y: 70 },
}
/** 九宫格位置选项 */
export const GRID_POSITIONS: PipGridPosition[] = [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
]
/** 入场动画选项 */
export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
{ value: "none", label: "无" },
{ value: "fade_in", label: "淡入" },
{ value: "slide_in", label: "滑入" },
]
/** 滑入方向选项 */
export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
{ value: "left", label: "← 左" },
{ value: "right", label: "→ 右" },
{ value: "up", label: "↑ 上" },
{ value: "down", label: "↓ 下" },
]
/** 预览图层颜色池 */
export const LAYER_COLORS = [
"rgba(22,119,255,0.5)",
"rgba(82,196,26,0.5)",
"rgba(250,173,20,0.5)",
"rgba(255,77,79,0.5)",
"rgba(114,46,209,0.5)",
"rgba(19,194,194,0.5)",
]
@@ -0,0 +1,64 @@
/**
* StickerPanel 常量定义
*/
import type { TextStickerPreset } from "@/pages/editing-planner/types"
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/types"
export { TEXT_STICKER_PRESET_LABELS }
/** 常用 emoji 素材 */
export const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
]
/** 贴纸类型 Tab */
export const STICKER_TYPE_TABS = [
{ value: "emoji" as const, label: "表情贴纸" },
{ value: "image" as const, label: "图片贴纸" },
{ value: "text" as const, label: "文字花字" },
]
/** 文字花字预设对应的 CSS 样式预览 */
export const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
normal: { color: "#fff", textShadow: "none" },
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
gradient: {
color: "transparent",
background: "linear-gradient(90deg,#f093fb,#f5576c)",
WebkitBackgroundClip: "text",
},
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
}
@@ -0,0 +1,30 @@
/**
* SubtitleStylePanel 相关常量
*/
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
export const ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]
@@ -0,0 +1,19 @@
/**
* TtsPanel 相关常量
*/
import type { TtsMode } from "../types"
export const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
}
export const TTS_MODE_OPTIONS: { mode: TtsMode; icon: string; label: string }[] = [
{ mode: "none", icon: "🔇", label: "无配音" },
{ mode: "upload", icon: "📁", label: "上传配音" },
{ mode: "tts", icon: "🤖", label: "TTS 合成" },
]
@@ -0,0 +1,25 @@
/**
* WatermarkPanel 相关常量
*/
import type { WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
export const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
{ key: "none", label: "无水印", icon: "🚫" },
{ key: "image", label: "图片水印", icon: "🖼️" },
{ key: "text", label: "文字水印", icon: "📝" },
{ key: "scroll", label: "滚动水印", icon: "📜" },
]
export const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
{ value: "top_left", label: "左上角" },
{ value: "top_right", label: "右上角" },
{ value: "bottom_left", label: "左下角" },
{ value: "bottom_right", label: "右下角" },
{ value: "center", label: "居中" },
]
export const SCROLL_DIRECTION_OPTIONS: { value: ScrollDirection; label: string }[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
]
@@ -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 @@
/**
* Hook
* PR#389 CRUD API config.segments
*
*
* - /
* -
* -
* -
* -
* - +
*/
import { useCallback, useState } from "react"
import { useCallback } from "react"
import { message } from "antd"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import { useQueryClient, useMutation } from "@tanstack/react-query"
import type {
EditPlanClip,
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
ClipReorderItem,
} from "@/api/template-editor"
import {
getEditPlanClips,
createEditPlanClip,
updateEditPlanClip,
deleteEditPlanClip,
@@ -28,51 +14,37 @@ import {
batchDeleteEditPlanClips,
createClipsFromAssets,
} from "@/api/template-editor"
import { useUndoRedo } from "./useUndoRedo"
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 {
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 invalidate = () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
}
/* ── 创建片段 ── */
const createMutation = useMutation({
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
invalidate()
message.success("片段已添加")
},
onError: () => {
@@ -83,10 +55,10 @@ export function useEditPlanClips(planId: string | undefined) {
const addClip = useCallback(
(data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => {
if (!planId) return
const order = data.order ?? clips.length
const order = data.order ?? clipsLength
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 }) =>
updateEditPlanClip(planId!, clipId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
invalidate()
},
onError: () => {
message.error("更新片段失败")
@@ -113,7 +85,7 @@ export function useEditPlanClips(planId: string | undefined) {
const deleteMutation = useMutation({
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
invalidate()
message.success("片段已删除")
},
onError: () => {
@@ -129,14 +101,14 @@ export function useEditPlanClips(planId: string | undefined) {
}
deleteMutation.mutate(clipId)
},
[planId, selectedClipId, deleteMutation],
[planId, selectedClipId, setSelectedClipId, deleteMutation],
)
/* ── 批量删除 ── */
const batchDeleteMutation = useMutation({
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
invalidate()
message.success(`已删除 ${res.deleted_count} 个片段`)
},
onError: () => {
@@ -152,19 +124,18 @@ export function useEditPlanClips(planId: string | undefined) {
}
batchDeleteMutation.mutate(clipIds)
},
[planId, selectedClipId, batchDeleteMutation],
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
)
/* ── 重排序(拖拽结束后一次性提交) ── */
/* ── 重排序 ── */
const reorderMutation = useMutation({
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
invalidate()
},
onError: () => {
message.error("排序失败")
// 失败后刷新回服务端状态
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
invalidate()
},
})
@@ -180,7 +151,7 @@ export function useEditPlanClips(planId: string | undefined) {
const importFromAssetsMutation = useMutation({
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
invalidate()
message.success(`已导入 ${res.created_count} 个素材片段`)
},
onError: () => {
@@ -197,37 +168,16 @@ export function useEditPlanClips(planId: string | undefined) {
)
return {
// 数据
clips,
clipsTotal,
clipsLoading,
selectedClipId,
selectedClip,
// 选中
setSelectedClipId,
// 操作
addClip,
updateClip,
removeClip,
batchRemoveClips,
reorderClips,
importFromAssets,
refetchClips,
// 状态
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
isReordering: reorderMutation.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,129 @@
/**
* 混剪图层管理 Hook
*/
import { useState, useCallback, useMemo } from "react"
import type { PipConfig, PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "@/pages/editing-planner/types"
import { GRID_POSITION_MAP } from "@/pages/editing-planner/constants/pipConfig"
let layerIdCounter = 0
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
interface UsePipLayersOptions {
config: PipConfig
onChange: (config: PipConfig) => void
}
export const usePipLayers = ({ config, onChange }: UsePipLayersOptions) => {
const [selectedId, setSelectedId] = useState<string>("")
const selectedLayer = useMemo(
() => config.layers.find((l) => l.id === selectedId) ?? null,
[config.layers, selectedId],
)
/* ── 添加图层 ── */
const handleAddLayer = useCallback(() => {
const newLayer: PipLayer = {
...DEFAULT_PIP_LAYER,
id: genLayerId(),
name: `图层 ${config.layers.length + 1}`,
z_index: config.layers.length + 1,
}
onChange({
...config,
layers: [...config.layers, newLayer],
})
setSelectedId(newLayer.id)
}, [config, onChange])
/* ── 删除图层 ── */
const handleDeleteLayer = useCallback(
(id: string) => {
const newLayers = config.layers.filter((l) => l.id !== id)
onChange({ ...config, layers: newLayers })
if (selectedId === id) {
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
}
},
[config, onChange, selectedId],
)
/* ── 更新图层 ── */
const updateLayer = useCallback(
(id: string, partial: Partial<PipLayer>) => {
onChange({
...config,
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
})
},
[config, onChange],
)
/* ── 切换启用 ── */
const handleEnableToggle = useCallback(
(checked: boolean) => {
onChange({ ...config, enabled: checked })
},
[config, onChange],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_PIP_CONFIG })
setSelectedId("")
}, [onChange])
/* ── 九宫格点击 ── */
const handleGridClick = useCallback(
(pos: PipGridPosition) => {
if (!selectedLayer) return
const coords = GRID_POSITION_MAP[pos]
updateLayer(selectedLayer.id, {
grid_position: pos,
x: coords.x,
y: coords.y,
})
},
[selectedLayer, updateLayer],
)
/* ── 宽高比锁定 ── */
const handleWidthChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { width: val }
if (selectedLayer.aspect_lock) {
partial.height = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
const handleHeightChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { height: val }
if (selectedLayer.aspect_lock) {
partial.width = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
return {
selectedId,
setSelectedId,
selectedLayer,
handleAddLayer,
handleDeleteLayer,
updateLayer,
handleEnableToggle,
handleReset,
handleGridClick,
handleWidthChange,
handleHeightChange,
}
}
@@ -0,0 +1,83 @@
/**
* 贴纸项管理 Hook
*/
import { useState, useCallback } from "react"
import type { StickerConfig, StickerItem, StickerType } from "@/pages/editing-planner/types"
import { DEFAULT_STICKER_ITEM, DEFAULT_STICKER_CONFIG } from "@/pages/editing-planner/types"
const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
interface UseStickerItemsOptions {
config: StickerConfig
onChange: (config: StickerConfig) => void
totalDuration: number
}
export const useStickerItems = ({ config, onChange, totalDuration }: UseStickerItemsOptions) => {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null
/** 更新单个贴纸 */
const updateItem = useCallback(
(id: string, partial: Partial<StickerItem>) => {
onChange({
...config,
items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)),
})
},
[config, onChange],
)
/** 添加贴纸 */
const addSticker = useCallback(
(type: StickerType, content: string) => {
const newItem: StickerItem = {
...DEFAULT_STICKER_ITEM,
id: genId(),
type,
content,
duration: totalDuration > 0 ? totalDuration : 5,
z_index: config.items.length + 1,
}
onChange({
...config,
enabled: true,
items: [...config.items, newItem],
})
setSelectedId(newItem.id)
},
[config, onChange, totalDuration],
)
/** 删除贴纸 */
const removeSticker = useCallback(
(id: string) => {
onChange({
...config,
items: config.items.filter((s) => s.id !== id),
})
if (selectedId === id) setSelectedId(null)
},
[config, onChange, selectedId],
)
/** 重置所有 */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled })
setSelectedId(null)
}, [config.enabled, onChange])
return {
selectedId,
setSelectedId,
activeTab,
setActiveTab,
selectedSticker,
updateItem,
addSticker,
removeSticker,
handleReset,
}
}
@@ -0,0 +1,152 @@
/**
* TTS 配音 Hook
* 管理音色加载、试听、配置变更
*/
import { useState, useCallback, useEffect, useRef } from "react"
import { message } from "antd"
import type { TtsConfig, TtsMode } from "../types"
import { DEFAULT_TTS_CONFIG } from "../types"
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
interface UseTtsPanelOptions {
open: boolean
config: TtsConfig
onChange: (config: TtsConfig) => void
onClose: () => void
}
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── 加载音色列表 ── */
useEffect(() => {
if (!open) return
setVoicesLoading(true)
getTtsVoices()
.then((v) => setVoices(v))
.catch(() => message.error("加载音色列表失败"))
.finally(() => setVoicesLoading(false))
}, [open])
/* ── 切换配音模式 ── */
const handleModeChange = useCallback(
(mode: TtsMode) => {
onChange({ ...config, mode })
},
[config, onChange],
)
/* ── 文本输入 ── */
const handleTextChange = useCallback(
(text: string) => {
onChange({ ...config, text: text.slice(0, 5000) })
},
[config, onChange],
)
/* ── 选择音色 ── */
const handleVoiceSelect = useCallback(
(voiceId: string) => {
onChange({ ...config, voice_id: voiceId })
},
[config, onChange],
)
/* ── 语速 ── */
const handleSpeedChange = useCallback(
(speed: number) => {
onChange({ ...config, speed })
},
[config, onChange],
)
/* ── 语调 ── */
const handlePitchChange = useCallback(
(pitch: number) => {
onChange({ ...config, pitch })
},
[config, onChange],
)
/* ── 音量 ── */
const handleVolumeChange = useCallback(
(volume: number) => {
onChange({ ...config, volume })
},
[config, onChange],
)
/* ── 字幕联动 ── */
const handleSubtitleSyncToggle = useCallback(() => {
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
}, [config, onChange])
/* ── 试听 ── */
const handlePreview = useCallback(async () => {
if (!config.text.trim()) {
message.warning("请先输入合成文本")
return
}
if (!config.voice_id) {
message.warning("请先选择音色")
return
}
setPreviewLoading(true)
try {
const res = await previewTts({
text: config.text.slice(0, 200),
voice_id: config.voice_id,
speed: config.speed,
pitch: config.pitch,
})
audioRef.current?.pause()
const audio = new Audio(res.audio_url)
audioRef.current = audio
audio.play().catch(() => message.error("播放失败"))
audio.onended = () => {
audioRef.current = null
}
message.success("试听播放中")
} catch {
message.error("试听生成失败")
} finally {
setPreviewLoading(false)
}
}, [config])
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG })
}, [onChange])
/* ── 关闭时停止音频 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause()
audioRef.current = null
onClose()
}, [onClose])
return {
voices,
voicesLoading,
previewLoading,
handleModeChange,
handleTextChange,
handleVoiceSelect,
handleSpeedChange,
handlePitchChange,
handleVolumeChange,
handleSubtitleSyncToggle,
handlePreview,
handleReset,
handleClose,
}
}
export default useTtsPanel
@@ -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,
}
}
@@ -0,0 +1,42 @@
/**
* 配音试听 Hook
*/
import { useRef, useState, useCallback } from "react"
import type { AssetItem } from "@/api/assets"
export const useVoicePreview = () => {
const audioRef = useRef<HTMLAudioElement | null>(null)
const [previewingId, setPreviewingId] = useState<string | null>(null)
/** 试听配音素材 */
const handlePreviewVoice = useCallback(
(asset: AssetItem) => {
if (previewingId === asset.id) {
audioRef.current?.pause()
setPreviewingId(null)
return
}
audioRef.current?.pause()
const url = asset.file_url || (asset.metadata?.preview_url as string)
if (!url) return
const audio = new Audio(url)
audioRef.current = audio
audio.play().catch(() => {})
audio.onended = () => setPreviewingId(null)
setPreviewingId(asset.id)
},
[previewingId],
)
/** 停止试听 */
const stopPreview = useCallback(() => {
audioRef.current?.pause()
setPreviewingId(null)
}, [])
return {
previewingId,
handlePreviewVoice,
stopPreview,
}
}
@@ -0,0 +1,126 @@
/**
* 水印配置 Hook
* 管理水印类型切换、各项配置变更
*/
import { useState, useCallback } from "react"
import type { WatermarkConfig, WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
import { DEFAULT_WATERMARK } from "../types"
interface UseWatermarkConfigOptions {
config: WatermarkConfig
onChange: (config: WatermarkConfig) => void
}
export const useWatermarkConfig = ({ config, onChange }: UseWatermarkConfigOptions) => {
/* ── 图片上传预览 URL(本地预览用) ── */
const [localImageUrl, setLocalImageUrl] = useState<string>("")
/* ── 切换水印类型 ── */
const handleTypeChange = useCallback(
(type: WatermarkType) => {
setLocalImageUrl("")
onChange({ ...DEFAULT_WATERMARK, type })
},
[onChange],
)
/* ── 通用设置变更 ── */
const handlePositionChange = useCallback(
(position: WatermarkPosition) => {
onChange({ ...config, position })
},
[config, onChange],
)
const handleOpacityChange = useCallback(
(opacity: number) => {
onChange({ ...config, opacity })
},
[config, onChange],
)
/* ── 图片水印设置 ── */
const handleImageUrlChange = useCallback(
(url: string) => {
setLocalImageUrl(url)
onChange({ ...config, image_url: url })
},
[config, onChange],
)
const handleImageWidthChange = useCallback(
(width: number) => {
onChange({ ...config, width })
},
[config, onChange],
)
const handleImageHeightChange = useCallback(
(height: number) => {
onChange({ ...config, height })
},
[config, onChange],
)
/* ── 文字水印设置 ── */
const handleTextChange = useCallback(
(text: string) => {
onChange({ ...config, text })
},
[config, onChange],
)
const handleFontSizeChange = useCallback(
(font_size: number) => {
onChange({ ...config, font_size })
},
[config, onChange],
)
const handleColorChange = useCallback(
(color: string) => {
onChange({ ...config, color })
},
[config, onChange],
)
/* ── 滚动水印设置 ── */
const handleScrollDirectionChange = useCallback(
(scroll_direction: ScrollDirection) => {
onChange({ ...config, scroll_direction })
},
[config, onChange],
)
const handleScrollSpeedChange = useCallback(
(scroll_speed: number) => {
onChange({ ...config, scroll_speed })
},
[config, onChange],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
setLocalImageUrl("")
onChange({ ...DEFAULT_WATERMARK })
}, [onChange])
return {
localImageUrl,
activeTab: config.type,
handleTypeChange,
handlePositionChange,
handleOpacityChange,
handleImageUrlChange,
handleImageWidthChange,
handleImageHeightChange,
handleTextChange,
handleFontSizeChange,
handleColorChange,
handleScrollDirectionChange,
handleScrollSpeedChange,
handleReset,
}
}
export default useWatermarkConfig
-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
}
@@ -0,0 +1,71 @@
/**
* ClipPropertiesPanel 相关类型定义
*/
import type { ClipData } from "./clip"
import type { TemplateMode } from "@/api/editing-planner"
import type { AssetItem } from "@/api/assets"
export interface SubtitleSettings {
enabled: boolean
position: string
font: string
fontSize: number
fontColor: string
animation: string
mode?: string
stroke?: boolean
shadow?: boolean
asrLanguage?: string
}
export interface BgmSettings {
enabled: boolean
music_id: string
volume?: number
fade_in?: number
fade_out?: number
voice_dodge?: boolean
}
export interface ClipPropertiesPanelProps {
selectedClip: ClipData | null
subtitleSettings: SubtitleSettings
bgmSettings: BgmSettings
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
/** 打开 BGM 选择器 Drawer */
onOpenBgmDrawer?: () => void
/** 打开字幕样式配置 Drawer */
onOpenSubtitleDrawer?: () => void
/** 配音素材列表(从配音库 API 获取) */
voiceMaterials?: AssetItem[]
/** 配音素材加载中 */
voiceMaterialsLoading?: boolean
/** 刷新配音素材列表 */
onRefreshVoiceMaterials?: () => void
/** 为片段选择配音素材 */
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
/** 打开转场特效选择器 Drawer */
onOpenTransitionDrawer?: (clipId: string) => void
/** 打开片段调速面板 Drawer */
onOpenSpeedDrawer?: (clipId: string) => void
/** 打开 TTS 配音面板 Drawer */
onOpenTtsDrawer?: (clipId: string) => void
/** 打开水印设置面板 Drawer */
onOpenWatermarkDrawer?: () => void
/** 打开片头片尾设置面板 Drawer */
onOpenIntroOutroDrawer?: () => void
/** 打开混剪设置面板 Drawer */
onOpenPipDrawer?: () => void
/** 打开滤镜调色面板 Drawer */
onOpenFilterDrawer?: () => void
/** 打开绿幕抠像面板 Drawer */
onOpenGreenScreenDrawer?: () => void
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void
/** 打开封面选择器 Drawer */
}
@@ -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,21 @@
/**
* ClipPropertiesPanel 工具函数
*/
import type { AssetItem } from "@/api/assets"
import type { TemplateMode } from "@/api/editing-planner"
/** 从 metadata 取性别标签 */
export const getGenderLabel = (m: AssetItem): string => {
const g = (m.metadata?.gender as string) || ""
if (g === "male") return "男"
if (g === "female") return "女"
return ""
}
/** 格式化模式标签 */
export const formatModeLabel = (mode: TemplateMode): string => {
if (mode === "pip") return "混剪"
if (mode === "voice_over") return "人物口播"
if (mode === "one_take") return "一镜到底"
return "口播+混剪"
}
@@ -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
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
*/
import { useState, useRef, useCallback, useEffect, useMemo } 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 { useCallback } from "react"
import type { VoiceClone } from "@/api/voice-clone"
import { message } from "antd"
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 {
selectedVoice: string
@@ -43,91 +41,47 @@ export function useStep5Voice({
onCloneModalOpenChange,
titleText,
}: UseStep5VoiceProps) {
const navigate = useNavigate()
/* ── 预置音色 API ── */
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── 子模块 ── */
const { playingVoice, toggleVoicePlay } = useVoiceAudio()
/* ── 音频播放 ── */
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
const {
presetVoices,
presetVoicesLoading,
voiceRecommendLoading,
voiceRecommendations,
hasVoiceRecommend,
handleVoiceRecommend,
} = useVoiceRecommend(titleText)
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],
)
const {
customVoiceText,
setCustomVoiceText,
customAudioUrl,
ttsError,
ttsJobId,
completedTtsJobId,
synthesizeMutation,
handleSynthesizeVoice,
resetTtsState,
} = useTtsSynthesis(selectedVoice)
/* ── 智能配音推荐 ── */
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 {
// 默认推荐前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 {
saveModalOpen,
setSaveModalOpen,
saveName,
setSaveName,
saveTagIds,
setSaveTagIds,
saveNewTag,
setSaveNewTag,
allTags,
saveToLibraryMutation,
handleOpenSaveModal,
handleConfirmSave,
handleAddTagInModal,
} = useSaveToLibrary(completedTtsJobId, resetTtsState)
/* ── 推荐音色选择 ── */
const handleSelectRecommendedVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
@@ -136,173 +90,6 @@ export function useStep5Voice({
[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(
(voice: VoiceClone) => {
+24 -167
View File
@@ -3,126 +3,39 @@
* 卡片视图展示用户已保存的剪辑模板
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
*/
import React, { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
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 React from "react"
import { Typography, Input, Select, Button, Empty, Spin, Row, Col } from "antd"
import { SearchOutlined, AppstoreOutlined, PlusOutlined } from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import {
getEditingTemplates,
getTemplateCategories,
deleteEditingTemplate,
createEditingTemplate,
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateMode,
} from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
import { useMyTemplates } from "./hooks/useMyTemplates"
import { TemplateCard } from "./components/TemplateCard"
import "./MyTemplates.css"
const { Title, Text } = Typography
const MyTemplates: React.FC = () => {
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) => {
navigate(`/editing-planner?template=${tpl.id}`)
}
const handleGenerate = (tpl: EditingTemplate) => {
// 跳转到智能剪辑页面,统一从智能剪辑出片
navigate(`/generate?templateId=${tpl.id}`)
}
const handleCopy = (tpl: EditingTemplate) => {
copyMutation.mutate(tpl)
}
const handleDelete = (id: string) => {
deleteMutation.mutate(id)
}
return (
<div className="mt-page">
{/* 页面头部 */}
@@ -179,69 +92,13 @@ const MyTemplates: React.FC = () => {
<Row gutter={[16, 16]}>
{templates.map((tpl) => (
<Col key={tpl.id} xs={24} sm={12} md={8} lg={6}>
<Card
className="mt-card"
hoverable
actions={[
<Tooltip title="编辑" key="edit">
<EditOutlined onClick={() => handleEdit(tpl)} />
</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>
<TemplateCard
tpl={tpl}
onEdit={handleEdit}
onCopy={handleCopy}
onGenerate={handleGenerate}
onDelete={handleDelete}
/>
</Col>
))}
</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 设计系统
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
* 使用 useQuery 对接后端真实 APIapi/products.ts
*
* 代码结构(三阶段重构后):
* - types.ts: 类型定义
* - constants.ts: 常量配置
* - utils/index.ts: 工具函数
* - components/ProductCard.tsx: 产品卡片组件
* - components/VideoPlayer.tsx: 视频播放器组件
* - hooks/useProductList.ts: 列表查询与筛选
* - hooks/useProductActions.ts: 单个/批量操作
* 主组件仅保留 Hook 组装与整体布局
* 列表查询 → hooks/useProductList
* 操作逻辑 → hooks/useProductActions
* 筛选栏 → components/ProductFilterBar
* 批量操作栏 → components/ProductBatchBar
* 空状态 → components/ProductEmptyState
* 产品卡片 → components/ProductCard
* 视频播放 → components/VideoPlayer
*/
import React, { useState } from "react"
import { Popconfirm, message } from "antd"
import {
SearchOutlined,
VideoCameraOutlined,
DownloadOutlined,
DeleteOutlined,
CheckOutlined,
CloudUploadOutlined,
} from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { ProductItem } from "./types"
import { ProductCard } from "./components/ProductCard"
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 { useProductActions } from "./hooks/useProductActions"
import "./products.css"
/* ============================================================
* 主组件
* ============================================================ */
const ProductLibrary: React.FC = () => {
const {
products,
@@ -83,58 +74,20 @@ const ProductLibrary: React.FC = () => {
setPlayingProduct,
})
// ── Loading 状态 ──
if (isLoading) {
return (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>...</p>
</div>
</div>
)
return <ProductEmptyState type="loading" />
}
// ── Error 状态 ──
if (isError) {
console.error("[ProductLibrary] 加载失败:", error)
const errorMsg = error?.message || "加载失败"
// 404 视为空数据(API 尚未就绪或无数据)
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found")
if (is404) {
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>
)
return <ProductEmptyState type="404" />
}
return (
<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 <ProductEmptyState type="error" errorMessage={errorMsg} onRetry={refetch} />
}
return (
@@ -153,129 +106,35 @@ const ProductLibrary: React.FC = () => {
{/* 批量操作栏 */}
{batchMode && (
<div className="xx-products-batch-bar">
<div className="xx-products-batch-bar-left">
<div
className={`xx-products-checkbox${allSelected ? " checked" : ""}`}
onClick={handleSelectAll}
>
{allSelected && <CheckOutlined />}
</div>
<span className="xx-products-select-all" onClick={handleSelectAll}>
{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>
<ProductBatchBar
allSelected={allSelected}
selectedCount={selectedIds.size}
batchDownloading={batchDownloading}
onSelectAll={handleSelectAll}
onBatchDownload={handleBatchDownload}
onBatchPublish={handleBatchPublish}
onBatchDelete={handleBatchDelete}
onClearSelection={clearSelection}
/>
)}
{/* 筛选栏 */}
<div className="xx-products-filters">
<div className="xx-products-filters-left">
<Input
placeholder="搜索成片名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterStatus}
onChange={setFilterStatus}
style={{ width: 120 }}
options={[
{ 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>
<ProductFilterBar
searchText={searchText}
onSearchChange={setSearchText}
filterStatus={filterStatus}
onFilterStatusChange={setFilterStatus}
filterTime={filterTime}
onFilterTimeChange={setFilterTime}
filterDuration={filterDuration}
onFilterDurationChange={setFilterDuration}
filterProject={filterProject}
onFilterProjectChange={setFilterProject}
filterReviewStatus={filterReviewStatus}
onFilterReviewStatusChange={setFilterReviewStatus}
projectOptions={projectOptions}
resultCount={filteredProducts.length}
/>
{/* 卡片网格 */}
{filteredProducts.length > 0 ? (
@@ -297,19 +156,7 @@ const ProductLibrary: React.FC = () => {
))}
</div>
) : (
<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>
<ProductEmptyState type="empty" />
)}
{/* 视频播放弹窗 */}
@@ -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 组件
*/
import React, { useState, useEffect } from "react"
import { message } from "antd"
import { Button, Modal } from "@/components/ui"
import React from "react"
import { Modal } from "@/components/ui"
import { useNavigate } from "react-router-dom"
import {
getCurrentSubscription,
changePlan,
toggleAutoRenew,
cancelSubscription,
} from "@/api/subscription"
import type { SubscriptionInfo, PlanType, BillingCycle } from "@/api/subscription"
import type { PlanType } from "@/api/subscription"
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"
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 navigate = useNavigate()
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 {
subscription,
loading,
submitting,
selectedPlan,
billingCycle,
setSelectedPlan,
setBillingCycle,
executeChangePlan,
handleToggleAutoRenew,
handleCancel,
} = useSubscription()
useEffect(() => {
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 () => {
const handleUpgradeClick = () => {
if (!subscription) return
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
message.info("当前已是该套餐")
return
}
const plan = PLANS_META[selectedPlan]
const price = billingCycle === "yearly" ? plan.yearlyPrice : plan.price
const price = getPlanPrice(selectedPlan, billingCycle)
Modal.confirm({
title: "确认变更套餐",
content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`,
okText: "确认变更",
cancelText: "取消",
onOk: 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)
}
},
onOk: executeChangePlan,
})
}
const handleToggleAutoRenew = 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("操作失败")
}
}
const handleCancel = () => {
const handleCancelClick = () => {
Modal.confirm({
title: "确认取消订阅",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
okText: "确认取消",
cancelText: "再想想",
onOk: async () => {
try {
const res = await cancelSubscription()
message.success(res.message)
navigate("/app/subscription")
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
}
const ok = await handleCancel()
if (ok) navigate("/app/subscription")
},
})
}
@@ -163,10 +71,7 @@ const UpgradeSubscription: React.FC = () => {
return (
<div className="xx-upgrade-page">
<PageHead
title="变更订阅方案"
description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`}
/>
<PageHead title="变更订阅方案" description={`当前套餐:${getPlanName(currentPlan)}`} />
<div className="xx-upgrade-plans">
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
@@ -198,7 +103,7 @@ const UpgradeSubscription: React.FC = () => {
buttonType="primary"
buttonSize="lg"
disabled={submitting || selectedPlan === currentPlan}
onClick={handleUpgrade}
onClick={handleUpgradeClick}
>
{submitting ? "处理中..." : "确认变更"}
</Button>
@@ -220,7 +125,7 @@ const UpgradeSubscription: React.FC = () => {
<Button
buttonType="danger"
buttonSize="sm"
onClick={handleCancel}
onClick={handleCancelClick}
className="xx-cancel-btn"
>

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