Compare commits

...

66 Commits

Author SHA1 Message Date
xiaoxia ea65033b01 fix(worker): 修复瘦身后PYTHONPATH缺少packages目录导致shared模块无法导入
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m26s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
2026-07-15 08:30:58 +08:00
xiaoxia ca8b945054 perf(ci): Worker镜像瘦身 - 多阶段构建+ffmpeg静态替换+Python依赖瘦身
CI/CD Pipeline / Frontend Lint (push) Successful in 1m4s
CI/CD Pipeline / Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m38s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Build Staging Web Image (push) Successful in 44s
CI/CD Pipeline / Integration Tests (push) Successful in 1m13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 10m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 9m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m0s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m52s
2026-07-15 07:49:15 +08:00
xiaoxia 9e9725015e Merge pull request 'fix(code-quality): 第一批代码质量修复 - 零风险项' (#336) from fix/code-quality-batch1 into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m3s
CI/CD Pipeline / Unit Tests (push) Successful in 1m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m3s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI/CD Pipeline / Build Staging API 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
2026-07-15 07:38:54 +08:00
xiaoxia 164246c0b0 Merge pull request 'fix(ci): 修复Staging部署脚本调用方式(cat而非bash)' (#339) from fix/staging-ssh-deploy-call into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Unit Tests (push) Successful in 1m54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 10m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
2026-07-15 02:56:16 +08:00
xiaoxia d6549ebf40 fix(ci): 修复Staging部署脚本调用方式(cat而非bash)
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m10s
2026-07-15 02:52:33 +08:00
xiaoxia e2bc857e05 Merge pull request 'fix(ci): 修复Deploy Staging SSH密钥检测语法错误' (#338) from fix/staging-ssh-syntax into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Unit Tests (push) Successful in 1m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m9s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 5s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-15 02:20:51 +08:00
xiaoxia fb2785f037 fix(ci): 修复Deploy Staging SSH密钥检测语法错误
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m12s
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m13s
2026-07-15 02:17:32 +08:00
CI Bot 03c789c212 fix(import): 修复audio_merger的shared模块import路径
CI/CD Pipeline / Frontend Lint (push) Successful in 1m48s
CI/CD Pipeline / Unit Tests (push) Successful in 2m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Build Staging Web Image (push) Successful in 47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 5s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-15 01:38:13 +08:00
xiaoxia 7d3328f462 Merge pull request 'feat(ci): Staging部署从Watchtower模式改为SSH push模式' (#337) from feat/staging-ssh-deploy into develop
CI/CD Pipeline / Unit Tests (push) Successful in 1m50s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 9m10s
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
2026-07-15 01:21:46 +08:00
xiaoxia c24f24258a Merge pull request 'fix(ci): 补PR - 修复CI Bot直接push的两个修复(isort + F811+单测魔数)' (#335) from fix/ci-bot-fix-pr into develop
CI/CD Pipeline / Validate Code Quality And Tests (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 / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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 Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 01:21:34 +08:00
XiaoXia Bot f2cea1831e feat(ci): Staging部署从Watchtower改为SSH push模式
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m41s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m52s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m21s
2026-07-15 00:52:18 +08:00
CI Bot 6cbd08f666 fix(code-quality): 第一批代码质量修复 - 零风险项
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m28s
- 修复 ruff 配置:移除已废弃规则 W503/E704(ruff 0.14+ 不兼容)
- 修复 F541 (13处):f-string 无占位符改为普通字符串
- 修复 B017 (1处):pytest.raises(Exception) 改为 ValidationError
- 修复 vulture 死代码:trim_engine 中 if False 的三元表达式
- 修复 mypy var-annotated:SUNSET_VERSIONS 加类型标注
2026-07-15 00:51:36 +08:00
CI Bot ede42dd3c4 fix(ci): 修复PR #332合并后CI失败 - F811重复import + url_security单测魔数校验
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m33s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m23s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
- generation.py: 删除重复的shared.url_security import(保留video_processing向后兼容层)
- test_url_security.py: mock数据添加ID3魔数头,适配新增的文件魔数校验
2026-07-15 00:42:18 +08:00
CI Bot b797e88b26 fix(lint): 修复generation.py isort import排序问题 2026-07-15 00:42:18 +08:00
xiaoxia 62ab361940 feat(ci): deploy-production接入健康检查+自动回滚
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Unit Tests (push) Successful in 2m2s
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 12m19s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
2026-07-15 00:31:08 +08:00
xiaoxia ed3ab34028 feat(ci): 升级部署通知脚本,卡片格式与CI通知对齐 2026-07-15 00:31:08 +08:00
xiaoxia df016b18fb feat(ci): 新增Production健康检查+自动回滚脚本 2026-07-15 00:31:08 +08:00
CI Bot c6aac862b1 fix(ci): 修复PR #332合并后CI失败 - F811重复import + url_security单测魔数校验
CI/CD Pipeline / Unit Tests (push) Successful in 1m52s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m22s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m50s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m55s
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
- generation.py: 删除重复的shared.url_security import(保留video_processing向后兼容层)
- test_url_security.py: mock数据添加ID3魔数头,适配新增的文件魔数校验
2026-07-15 00:20:36 +08:00
CI Bot 6232300fb1 fix(lint): 修复generation.py isort import排序问题
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47s
CI/CD Pipeline / Unit Tests (push) Failing after 1m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m55s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-15 00:14:18 +08:00
xiaoxia fe2ab121e7 fix(security): 后端安全技术债务第二轮(P1+P2+P2) (#332)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40s
CI/CD Pipeline / Unit Tests (push) Failing after 1m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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 Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
fix(security): 后端安全技术债务第二轮(P1+P2+P2)

- audio_merger裸subprocess下沉 + ffmpeg_utils架构下沉到packages/shared
- _verify_url_accessible重定向每跳SSRF校验
- url_security下载文件魔数校验
- 新增33个单测
2026-07-15 00:12:04 +08:00
xiaoxia 84db18a959 ci: re-trigger CI run after cache cleanup
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m14s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m16s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
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 Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m24s
CI/CD Pipeline / Unit Tests (push) Successful in 1m47s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m31s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m57s
2026-07-14 22:50:30 +08:00
xiaoxia 9501afe9b3 feat(ci): deploy-staging接入健康检查+自动回滚 2026-07-14 22:50:30 +08:00
xiaoxia 7407ec2957 feat(ci): 新增部署通知脚本 2026-07-14 22:49:19 +08:00
xiaoxia e45426fe7d feat(ci): 新增Staging健康检查脚本 2026-07-14 22:49:19 +08:00
xiaoxia c540d88306 fix(ci): staging smoke test添加版本号校验,防止部署假绿 2026-07-14 22:11:37 +08:00
xiaoxia 5fb49480e5 Merge PR #333: 飞书通知系统完善
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m2s
CI/CD Pipeline / Unit Tests (push) Successful in 2m5s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m26s
Merge PR #333: feat(ci) 飞书通知系统完善 - 结构化卡片+Runner信息+流程规范
2026-07-14 21:56:25 +08:00
xiaoxia d73fb2cf2b Merge pull request 'refactor: 消除全部 Record<string, unknown>,TypeScript 严格类型治理' (#331) from fix/frontend-type-debt-round2 into develop
CI/CD Pipeline / Unit Tests (push) Successful in 1m59s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32m8s
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
2026-07-14 21:15:56 +08:00
xiaoxia 0eb1262db6 feat(ci): 飞书通知卡片优化 - 结构化字段+Runner信息+阶段分类
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m37s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m52s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
2026-07-14 20:46:52 +08:00
CI Bot 9a6136c9fd Merge remote-tracking branch 'origin/develop' into fix/frontend-type-debt-round2
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m34s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m24s
2026-07-14 20:43:29 +08:00
xiaoxia 63f138e2d6 fix(ci): 确保飞书通知失败不阻塞部署流程
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Unit Tests (push) Successful in 2m3s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m15s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m38s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 12m38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m53s
- 所有notify步骤添加continue-on-error: true
- 通知标题增加'CI构建''部署'等关键词,提高飞书机器人匹配率
2026-07-14 20:22:56 +08:00
xiaoxia d898856f50 fix(ci): 确保飞书通知失败不阻塞部署流程
CI/CD Pipeline / Validate Code Quality And Tests (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 / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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 Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
- 所有notify步骤添加continue-on-error: true
- 通知标题增加'CI构建''部署'等关键词,提高飞书机器人匹配率
2026-07-14 20:22:56 +08:00
xiaoxia acffa364b3 fix(ci): 飞书通知返回错误码时也返回0,彻底不阻断CI流程
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Unit Tests (push) Successful in 2m1s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m11s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m42s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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
2026-07-14 20:19:01 +08:00
CI Bot 1931cab504 style: 用项目Prettier配置格式化products.ts和GeneratePage.tsx
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m54s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m41s
修复CI Prettier检查失败——之前构建运维用默认参数格式化,
与项目实际配置不一致,导致2个文件仍然挂掉。
现在用项目本地npx prettier --write重新格式化。
2026-07-14 20:17:42 +08:00
xiaoxia 9f9fd1ccb8 style: fix prettier formatting
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m1s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (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 / Integration Tests (pull_request) Successful in 1m22s
2026-07-14 19:50:36 +08:00
xiaoxia 9fd12c7fb3 style: fix prettier formatting 2026-07-14 19:50:35 +08:00
xiaoxia 4805e961f0 fix(ci): Worker构建超时调优 - mode=min + 超时40分钟
CI/CD Pipeline / Unit Tests (push) Successful in 1m51s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m21s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 19:48:27 +08:00
xiaoxia 1aeeb23621 fix(ci): 飞书通知标题增加关键词(CI通知/CI告警),解决Key Words Not Found
CI/CD Pipeline / Unit Tests (push) Successful in 1m47s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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
2026-07-14 19:45:19 +08:00
xiaoxia 4a21b89a0b fix(ci): 所有构建Job超时从20min调整到30min,防止Worker镜像推送超时
CI/CD Pipeline / Validate Code Quality And Tests (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 / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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 Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-14 19:45:06 +08:00
xiaoxia 2982ef5259 fix(ci): Frontend Lint缓存修复 - npm ci强制包含devDependencies
CI/CD Pipeline / Frontend Lint (push) Successful in 1m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m3s
CI/CD Pipeline / Unit Tests (push) Successful in 2m4s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m6s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 20m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 19:13:49 +08:00
xiaoxia 5fff7e518d fix(ci): 修复Frontend Lint缓存兼容性 - 先清空node_modules再安装 + Vitest fallback
CI/CD Pipeline / Frontend Lint (push) Failing after 1m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m0s
CI/CD Pipeline / Unit Tests (push) Successful in 2m1s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
2026-07-14 19:10:15 +08:00
xiaoxia a93149b8e5 fix(ci): 飞书通知失败不阻塞部署流程
CI/CD Pipeline / Frontend Lint (push) Successful in 1m49s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Unit Tests (push) Successful in 2m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
CI/CD Pipeline / Build Staging API 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
fix(ci): 飞书通知失败不阻塞部署流程
2026-07-14 19:04:57 +08:00
xiaoxia 21aaf642ec fix(ci): 通知失败不阻断CI主流程(飞书关键词不匹配不应导致部署失败)
CI/CD Pipeline / Frontend Lint (push) Failing after 1m53s
CI/CD Pipeline / Unit Tests (push) Successful in 2m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m14s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
2026-07-14 19:02:30 +08:00
CI Bot 8d86707e2e refactor: 消除全部 Record<string, unknown>,替换为具体类型定义
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m57s
- GeneratePage.tsx: 3处防御性错误提取函数添加 eslint-disable 注释
  (safeExtract/extractString/safeExtractErr),移除 as Record<string, unknown> 断言
- TemplateLibrary.tsx: formatConfig 使用 ConfigDisplayFields 接口替代 Record 断言
- VoiceLibrary.tsx: buildVoiceMetadata 返回 VoiceUploadMetadata 接口
- VoiceMaterialLibrary.tsx: buildMetadata 返回 VoiceAssetMetadata 接口
- editPlans.ts: EditPlanConfig 去除索引签名,补充所有生成时字段
- assets.ts: AssetItem.metadata 使用 AssetMetadata 具体类型
- products.ts: GenerateFromTemplateRequest.config 改为 EditPlanConfig
- tts.ts/voiceClone.ts: metadata 参数使用具体接口
- editingPlanner.ts: ValidationWarningDetails 替代 Record<string, unknown>
- client.ts: 防御性错误提取添加 eslint-disable 注释

TypeScript 编译零错误,代码库中零 Record<string, unknown> 残留
2026-07-14 18:41:25 +08:00
xiaoxia 752de50557 feat(ci): 通知卡片化+Frontend Lint缓存+全Job耗时统计
CI/CD Pipeline / Unit Tests (push) Successful in 1m48s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Build Staging API Image (push) Successful in 3s
CI/CD Pipeline / Integration Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 12m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 18:21:56 +08:00
xiaoxia ba6c5f2598 feat(ci): 统一飞书卡片通知脚本 - 支持开始/成功/失败三种状态
CI/CD Pipeline / Validate Code Quality And Tests (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 / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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 Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-14 18:21:41 +08:00
xiaoxia 78d1c88ca1 feat: 剪辑规划器完整交互 — 7大配置面板 + 时间轴拖拽/缩放/播放头 + 预览播放器 (#284)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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
2026-07-14 18:15:55 +08:00
xiaoxia c88be032c1 fix: 渲染引擎全链路安全加固 P0+P1 (#317)
CI/CD Pipeline / Validate Code Quality And Tests (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 / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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 Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-14 18:15:21 +08:00
xiaoxia 7f767e2dd1 feat(ci): Runner物理分层 - 构建任务专属旧服务器
CI/CD Pipeline / Unit Tests (push) Successful in 1m52s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m17s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m33s
CI/CD Pipeline / Build Staging API 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
2026-07-14 18:11:01 +08:00
xiaoxia ea93387f98 feat(ci): 代码质量深度加固 - mypy/ruff/vulture告警接入
CI/CD Pipeline / Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m38s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m56s
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m46s
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
feat(ci): 代码质量深度加固 - mypy/ruff/vulture 告警模式接入Validate

- ruff lint 告警模式接入:规则集与原flake8对齐 + bugbear摸底
- mypy 类型检查告警模式接入:检查核心业务代码
- vulture死代码扫描升级:置信度阈值70% + 按置信度排序
- 新增pyproject.toml ruff配置(保留原有black/isort/coverage配置)
- 所有新检查均为告警模式,不阻断CI
2026-07-14 17:56:15 +08:00
xiaoxia 7e172c0907 fix: PR#312安全债务 4个P1修复(路径安全+数量上限) (#322)
CI/CD Pipeline / Unit Tests (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m25s
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
2026-07-14 17:43:57 +08:00
xiaoxia 6db705a05d feat(ci): Runner物理分层 - 构建任务只调度到旧服务器
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m8s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m56s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
将6个构建Job(Staging+Production)的runs-on从saas改为[saas, build-farm],
只有带build-farm标签的旧服务器Runner能接构建任务,新服务器6个Runner
专注跑CI门禁检查,实现真正的物理分层,避免构建任务挤占CI检查资源。
2026-07-14 17:02:16 +08:00
xiaoxia 1c8cb20373 feat(ci): P2债务清理 + Production部署门禁
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Successful in 1m37s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m7s
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 / Integration Tests (push) Successful in 1m9s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m17s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 20m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
feat(ci): P2债务清理 + Production部署门禁

- 升级PyJWT 2.9.0 -> 2.13.0,修复4个CVE
- 清理5处死代码(未使用import和参数)
- Production部署加健康检查门禁(API health + 登录接口 + Web前端)
- deploy-production加checkout,修复通知脚本路径问题
2026-07-14 16:57:57 +08:00
xiaoxia 74c458e370 fix(ci): deploy-staging阶段添加checkout,修复通知脚本找不到的问题
CI/CD Pipeline / Unit Tests (push) Successful in 1m43s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m46s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m52s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m32s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m47s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m36s
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
fix(ci): deploy-staging阶段添加checkout,修复通知脚本找不到的问题
2026-07-14 16:31:25 +08:00
xiaoxia a7d942f705 feat(ci): 接入安全扫描(detect-secrets + pip-audit + vulture)
CI/CD Pipeline / Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m55s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m2s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m31s
CI/CD Pipeline / Build Staging API 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
feat(ci): 接入安全扫描(detect-secrets + pip-audit + vulture)
2026-07-14 16:25:07 +08:00
xiaoxia f867897348 fix(ci): 配置docker-container buildx builder
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 32s
CI/CD Pipeline / Unit Tests (push) Successful in 1m42s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Build Staging Web Image (push) Successful in 1m40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 12m19s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
合并buildx builder修复
2026-07-14 15:37:57 +08:00
xiaoxia 3d1b739e7f fix(ci): 配置docker-container buildx builder以支持cache export
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m11s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
新CI服务器默认docker driver不支持buildx cache export功能,
导致Staging/Production构建直接失败:
ERROR: Cache export is not supported for the docker driver.

在6个镜像构建Job中统一添加builder初始化步骤,
自动创建/使用docker-container类型的builder实例。
2026-07-14 15:29:43 +08:00
xiaoxia f268e208de feat: 多轨道混音 + 字幕渲染引擎 + 视频拼接(后端) (#312)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 33s
CI/CD Pipeline / Unit Tests (push) Successful in 1m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m18s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m27s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Build Staging API Image (push) Failing after 8s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 11s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 15:20:47 +08:00
xiaoxia b05966ff48 feat(ci): 新增CHANGELOG自动生成脚本 (#307)
CI/CD Pipeline / Validate Code Quality And Tests (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 / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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 Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-14 15:20:31 +08:00
xiaoxia 79b82978d8 feat(ci): 镜像并行构建 - 三镜像并行构建,构建时间从17min压缩到8-10min (#314)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 35s
CI/CD Pipeline / Unit Tests (push) Successful in 1m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m15s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 8s
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 10s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 15:08:43 +08:00
xiaoxia 08b51ffa1d feat(ci): Runner标签分层路由 - 测试/构建分机运行 (#315)
CI/CD Pipeline / Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m21s
CI/CD Pipeline / Build Production Runtime Images (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 / Integration Tests (push) Successful in 1m35s
CI/CD Pipeline / Build & Push 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
2026-07-14 14:56:08 +08:00
xiaoxia 23d2406c27 feat(ci): 集成gitleaks + pip-audit + vulture安全扫描到Validate阶段 (#311)
CI/CD Pipeline / Validate Code Quality And Tests (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 / Build & Push 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 Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-14 14:55:58 +08:00
xiaoxia def6ee2363 feat(ci): 新增发布与灰度部署脚本
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 46s
CI/CD Pipeline / Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m52s
CI/CD Pipeline / Build Production Runtime Images (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 / Integration Tests (push) Successful in 1m42s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 16m34s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m55s
合并PR #316:新增release.sh / gray_deploy.sh / rollback_gray.sh三个脚本,适配生产环境架构(nginx upstream权重 + canary容器)
2026-07-14 13:12:41 +08:00
xiaoxia 2b5b650b9e fix(ci): 修复生产部署SSH密钥查找失败 + 通知脚本路径问题 (#313)
CI/CD Pipeline / Staging E2E Tests (push) Failing after 29m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 38s
CI/CD Pipeline / Unit Tests (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 29m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m13s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-14 11:53:02 +08:00
xiaoxia 2b8326987e Merge pull request 'fix(e2e): 5步向导测试末尾加unrouteAll兜底,避免页面关闭时报错' (#308) from fix/e2e-unroute-all-stability into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 28s
CI/CD Pipeline / Unit Tests (push) Successful in 1m3s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m36s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push 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
2026-07-14 11:47:12 +08:00
xiaoxia 2081c72be6 feat: 视频调速引擎(快进/慢放) (#294)
CI/CD Pipeline / Unit Tests (push) Successful in 1m8s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m3s
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Production Runtime Images (push) Successful in 5m31s
CI/CD Pipeline / Deploy Production (push) Failing after 11s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push 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
2026-07-14 11:36:24 +08:00
CI Bot a681844a44 fix(e2e): 5步向导测试末尾加unrouteAll兜底,避免页面关闭时报错
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 43s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m17s
2026-07-14 11:32:03 +08:00
126 changed files with 23813 additions and 2380 deletions
+1
View File
@@ -0,0 +1 @@
re-trigger
+1 -1
View File
@@ -1 +1 @@
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
trigger: 1784009947
+1031 -1072
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
"""add playback_speed to edit_plan_clips
Revision ID: 040_playback_speed
Revises: 039_transition_duration
Create Date: 2026-07-14 10:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "040_playback_speed"
down_revision = "039_transition_duration"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column("playback_speed", sa.Float(), nullable=False, server_default="1.0"),
)
def downgrade() -> None:
op.drop_column("edit_plan_clips", "playback_speed")
+1
View File
@@ -211,6 +211,7 @@ class _PlanClipItem(BaseModel):
duration: float
transition_effect: str
transition_duration: float
playback_speed: float = 1.0
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
-1
View File
@@ -45,7 +45,6 @@ from packages.application.template.use_cases import (
CreateTemplateUseCase,
DeleteCategoryUseCase,
DeleteTemplateUseCase,
GetTemplateUsageUseCase,
GetTemplateUseCase,
ListCategoriesUseCase,
ListTagsUseCase,
+1 -1
View File
@@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
"""处理已下线的 API 版本"""
SUNSET_VERSIONS = [] # 已下线的版本列表
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表
async def dispatch(self, request: Request, call_next):
version = self._extract_version(request.url.path)
@@ -282,6 +282,7 @@ class EditPlanService:
duration: float = 0.0,
transition_effect: str = "cut",
transition_duration: float = 0.0,
playback_speed: float = 1.0,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""创建片段
@@ -303,6 +304,7 @@ class EditPlanService:
duration=duration,
transition_effect=transition_effect,
transition_duration=transition_duration,
playback_speed=playback_speed,
config=config,
)
created = self._clip_repo.create(clip)
@@ -327,6 +329,7 @@ class EditPlanService:
duration: Optional[float] = None,
transition_effect: Optional[str] = None,
transition_duration: Optional[float] = None,
playback_speed: Optional[float] = None,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""更新片段
@@ -336,6 +339,15 @@ class EditPlanService:
"""
existing = self.get_clip_or_raise(clip_id)
# 速度边界钳制
if playback_speed is not None:
if playback_speed <= 0:
playback_speed = 1.0
elif playback_speed < 0.25:
playback_speed = 0.25
elif playback_speed > 4.0:
playback_speed = 4.0
updated = EditPlanClip(
id=existing.id,
plan_id=existing.plan_id,
@@ -352,6 +364,7 @@ class EditPlanService:
transition_duration=(
transition_duration if transition_duration is not None else existing.transition_duration
),
playback_speed=playback_speed if playback_speed is not None else existing.playback_speed,
status=existing.status,
config=config if config is not None else existing.config,
created_at=existing.created_at,
+222 -68
View File
@@ -86,7 +86,10 @@ async function createProject(
): Promise<string> {
const resp = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
data: {
name: `Assets Test Proj ${suffix}`,
description: "E2E assets test",
},
});
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
@@ -178,20 +181,30 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-load");
const projectId = await createProject(
request,
"assets-load",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "默认视频库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
// 页面布局容器
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-page")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
// 左侧素材库列表
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
@@ -211,23 +224,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-create");
const projectId = await createProject(
request,
"assets-create",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "初始库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
// 点击新建素材库
await page.locator(".xx-asset-library-add").click();
// 弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
const modal = page
.locator(".ant-modal-content")
.filter({ hasText: "新建素材库" });
await expect(modal).toBeVisible();
// 填写表单
@@ -259,11 +282,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("切换不同素材库", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-switch");
const projectId = await createProject(
request,
"assets-switch",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const videoLibName = "视频素材库 A";
const imageLibName = "图片素材库 B";
@@ -292,10 +317,16 @@ test.describe("素材库页面 - 完整交互测试", () => {
"demo_video.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
// 点击视频库,应显示素材
const videoLibItem = page
@@ -305,7 +336,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await expect(videoLibItem).toHaveClass(/active/);
// 验证视频素材出现
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("demo_video.mp4")).toBeVisible({
timeout: 10_000,
});
// 点击图片库,应切换且不显示视频
const imageLibItem = page
@@ -315,18 +348,22 @@ test.describe("素材库页面 - 完整交互测试", () => {
await expect(imageLibItem).toHaveClass(/active/);
// 空状态或图片库内容
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
timeout: 5_000,
});
});
// ─── 素材搜索 ──────────────────────────────────────
test("素材搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-search");
const projectId = await createProject(
request,
"assets-search",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -336,13 +373,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建两个不同名称的素材
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"apple_clip.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"banana_clip.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
// 确保在测试库中
const libItem = page
@@ -351,7 +408,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 两个素材都应可见
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
// 搜索 apple,只显示 apple
@@ -361,7 +420,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
// 清空搜索,两个都显示
await page.getByPlaceholder("搜索素材名称...").fill("");
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
});
@@ -369,11 +430,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材类型筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-filter");
const projectId = await createProject(
request,
"assets-filter",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -383,12 +446,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建视频素材
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"video_clip.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -396,7 +472,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 素材应可见
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("video_clip.mp4")).toBeVisible({
timeout: 10_000,
});
// 筛选类型下拉存在
const filterSelect = page.locator(".xx-assets-filters-left select").first();
@@ -407,11 +485,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-detail");
const projectId = await createProject(
request,
"assets-detail",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -419,12 +499,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
"详情测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"play_test.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -441,7 +534,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await assetCard.locator(".xx-asset-play").click({ force: true });
// 播放弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
const modal = page
.locator(".ant-modal-content")
.filter({ hasText: "播放" });
await expect(modal).toBeVisible();
// 关闭弹窗
@@ -453,11 +548,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("删除素材 - 带确认对话框", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-delete");
const projectId = await createProject(
request,
"assets-delete",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -465,12 +562,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
"删除测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"to_delete.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -491,14 +601,15 @@ test.describe("素材库页面 - 完整交互测试", () => {
await deleteBtn.click({ force: true });
// 确认对话框出现
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
const confirmModal = page
.locator(".ant-popover")
.filter({ hasText: "确认删除" });
await expect(confirmModal).toBeVisible();
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/assets/") &&
resp.request().method() === "DELETE",
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
@@ -518,11 +629,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("批量删除素材", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-batch");
const projectId = await createProject(
request,
"assets-batch",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -532,14 +645,41 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建多个素材
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_1.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_2.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_3.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -547,7 +687,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 所有素材应可见
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("batch_1.mp4")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("batch_2.mp4")).toBeVisible();
await expect(page.getByText("batch_3.mp4")).toBeVisible();
@@ -567,7 +709,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await batchDeleteBtn.click();
// 确认对话框
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
const confirmPop = page
.locator(".ant-popover")
.filter({ hasText: "确定删除" });
await expect(confirmPop).toBeVisible();
// 确认删除
@@ -595,17 +739,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("空素材库展示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-empty");
const projectId = await createProject(
request,
"assets-empty",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "空素材库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -613,7 +765,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 空状态应显示
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
await expect(page.locator(".xx-assets-empty")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
});
+3
View File
@@ -251,6 +251,9 @@ test.describe("Core generation flow", () => {
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 15_000,
});
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
await page.unrouteAll({ behavior: "ignoreErrors" });
});
test("generation task API creates and lists tasks", async ({ request }) => {
+5 -3
View File
@@ -180,9 +180,11 @@ test.describe("Core media upload flow", () => {
await expect(page.locator(".xx-assets-content")).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
{
timeout: 20_000,
},
);
// Verify asset card shows status
const assetCard = page
+76 -38
View File
@@ -121,7 +121,11 @@ test.describe("去重流程", () => {
"dup-load",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication");
@@ -146,7 +150,11 @@ test.describe("去重流程", () => {
"dup-upload-zone",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -156,12 +164,12 @@ test.describe("去重流程", () => {
await expect(uploadZone).toBeVisible();
// 上传图标和文字
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
await expect(
uploadZone.getByText("点击或拖拽视频文件到此区域"),
).toBeVisible();
// 格式提示
await expect(
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
).toBeVisible();
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible();
// 格式标签
await expect(page.locator(".dup-upload-formats")).toBeVisible();
@@ -184,7 +192,11 @@ test.describe("去重流程", () => {
"dup-info",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -215,7 +227,11 @@ test.describe("去重流程", () => {
"dup-list",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
@@ -239,7 +255,11 @@ test.describe("去重流程", () => {
"dup-list-empty",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -257,7 +277,11 @@ test.describe("去重流程", () => {
"dup-filter",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -287,7 +311,11 @@ test.describe("去重流程", () => {
"dup-nav",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -306,10 +334,8 @@ test.describe("去重流程", () => {
request,
}) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-detail",
);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-detail");
// 先上传一个文件进行查重,获取 record id
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -335,7 +361,11 @@ test.describe("去重流程", () => {
const recordId = uploadData.id;
expect(recordId, "应返回查重记录 ID").toBeTruthy();
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
// 访问详情页
await page.goto(`/app/duplication/${recordId}`);
@@ -353,10 +383,8 @@ test.describe("去重流程", () => {
test("去重记录删除 - API 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete",
);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-delete");
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -419,10 +447,8 @@ test.describe("去重流程", () => {
test("去重记录删除 - UI 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete-ui",
);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-delete-ui");
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -443,14 +469,20 @@ test.describe("去重流程", () => {
return;
}
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录卡片应存在
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
const cardVisible = await resultCard
.isVisible({ timeout: 10_000 })
.catch(() => false);
if (cardVisible) {
// 删除按钮存在
@@ -467,12 +499,14 @@ test.describe("去重流程", () => {
});
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/duplication/records/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
).catch(() => null);
const deletePromise = page
.waitForResponse(
(resp) =>
resp.url().includes("/duplication/records/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
)
.catch(() => null);
await deleteBtn.click();
@@ -487,10 +521,8 @@ test.describe("去重流程", () => {
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-retry",
);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-retry");
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -511,14 +543,20 @@ test.describe("去重流程", () => {
return;
}
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录列表中至少有一条记录
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
const cardVisible = await resultCard
.isVisible({ timeout: 10_000 })
.catch(() => false);
if (cardVisible) {
// 验证记录卡片基本结构
+18 -3
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -258,7 +263,12 @@ test.describe("剪辑计划 - API 操作", () => {
mode: "pip",
estimated_duration: 30,
segments: [
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
{
segment_order: 1,
duration_min: 5,
duration_max: 10,
material_type: "video",
},
],
},
});
@@ -269,7 +279,12 @@ test.describe("剪辑计划 - API 操作", () => {
mode: "voice_over",
estimated_duration: 60,
segments: [
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
{
segment_order: 1,
duration_min: 10,
duration_max: 30,
material_type: "video",
},
],
},
});
+112 -29
View File
@@ -93,7 +93,8 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
resolution: "1080x1920",
file_size: (5 + i) * 1024 * 1024,
duplicate_rate: i * 5,
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
video_url:
status === "completed" ? "https://example.com/video.mp4" : undefined,
thumbnail_url: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -218,7 +219,11 @@ test.describe("作品库页面", () => {
const products = mockProducts(3, ["completed", "processing", "failed"]);
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
@@ -255,12 +260,24 @@ test.describe("作品库页面", () => {
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
{
...mockProducts(1, ["processing"])[0],
title: "处理中作品",
id: `mock-prod-${Date.now()}-p`,
},
{
...mockProducts(1, ["failed"])[0],
title: "失败作品",
id: `mock-prod-${Date.now()}-f`,
},
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
@@ -276,9 +293,9 @@ test.describe("作品库页面", () => {
const completedCard = page
.locator(".xx-product-card")
.filter({ hasText: "已完成作品" });
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
"已完成",
);
await expect(
completedCard.locator(".xx-product-status.completed"),
).toHaveText("已完成");
const processingCard = page
.locator(".xx-product-card")
@@ -309,7 +326,11 @@ test.describe("作品库页面", () => {
const productId = products[0].id;
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
// 直接访问详情页
await page.goto(`/app/products/${productId}`);
@@ -337,7 +358,11 @@ test.describe("作品库页面", () => {
products[0].video_url = "https://example.com/test-video.mp4";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -356,7 +381,10 @@ test.describe("作品库页面", () => {
// 播放弹窗出现 - 验证有视频元素或播放器容器
// (通过 Mock 的 video_urlvideo 元素应能渲染)
const videoEl = page.locator("video");
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
const videoVisible = await videoEl
.first()
.isVisible({ timeout: 5000 })
.catch(() => false);
// 或弹窗容器可见
const modalVisible = await page
.locator(".ant-modal-content")
@@ -380,7 +408,11 @@ test.describe("作品库页面", () => {
products[0].title = "下载测试作品";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -409,7 +441,11 @@ test.describe("作品库页面", () => {
products[0].title = "处理中下载测试";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -484,7 +520,11 @@ test.describe("作品库页面", () => {
route.continue();
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -507,9 +547,12 @@ test.describe("作品库页面", () => {
const { headers } = await createAuthedUser(request, "products-del-api");
// 测试删除不存在的产品,验证 API 端点存在
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
headers,
});
const resp = await request.delete(
`${apiBase}/products/nonexistent-test-id`,
{
headers,
},
);
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
// 404 表示资源不存在但端点存在
@@ -529,7 +572,11 @@ test.describe("作品库页面", () => {
// Mock 空列表
await mockProductsApi(page, []);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
@@ -553,12 +600,24 @@ test.describe("作品库页面", () => {
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
{
...mockProducts(1, ["completed"])[0],
title: "苹果宣传视频",
id: `mock-prod-${Date.now()}-apple`,
},
{
...mockProducts(1, ["completed"])[0],
title: "香蕉推广视频",
id: `mock-prod-${Date.now()}-banana`,
},
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -566,7 +625,9 @@ test.describe("作品库页面", () => {
});
// 两个作品都可见
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("苹果宣传视频")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText("香蕉推广视频")).toBeVisible();
// 搜索"苹果"
@@ -576,7 +637,9 @@ test.describe("作品库页面", () => {
// 清空搜索
await page.getByPlaceholder("搜索成片名称...").fill("");
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("香蕉推广视频")).toBeVisible({
timeout: 5_000,
});
});
test("作品状态筛选", async ({ page, request }) => {
@@ -587,12 +650,24 @@ test.describe("作品库页面", () => {
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
{
...mockProducts(1, ["completed"])[0],
title: "已完成筛选",
id: `mock-prod-${Date.now()}-done`,
},
{
...mockProducts(1, ["processing"])[0],
title: "处理中筛选",
id: `mock-prod-${Date.now()}-proc`,
},
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -629,7 +704,11 @@ test.describe("作品库页面", () => {
products[2].title = "批量测试 3";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -653,8 +732,12 @@ test.describe("作品库页面", () => {
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
// 批量按钮存在
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
await expect(
batchBar.getByRole("button", { name: "批量下载" }),
).toBeVisible();
await expect(
batchBar.getByRole("button", { name: "批量删除" }),
).toBeVisible();
// 取消选择
await batchBar.getByRole("button", { name: "取消选择" }).click();
+10 -2
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -401,7 +406,10 @@ test.describe("个人设置 - 退出登录", () => {
test.describe.configure({ timeout: 120_000 });
test("登出 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(request, "profile-logout");
const { headers, email } = await createAuthedUser(
request,
"profile-logout",
);
const response = await request.post(`${apiBase}/auth/logout`, {
headers,
+47 -12
View File
@@ -49,7 +49,9 @@ test.describe("注册页面", () => {
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
// 标题/描述
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
await expect(
page.getByText("创建账户,开启智能视频创作之旅"),
).toBeVisible();
// 表单字段
await expect(page.getByLabel("邮箱")).toBeVisible();
@@ -69,7 +71,10 @@ test.describe("注册页面", () => {
await page.goto("/register");
// 直接点击注册按钮
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示必填错误
await expect(page.getByText("请输入邮箱")).toBeVisible();
@@ -86,7 +91,10 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示邮箱格式错误
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
@@ -100,7 +108,10 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill("123");
await page.getByLabel("确认密码").fill("123");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示密码长度错误
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
@@ -114,7 +125,10 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill("Different123!");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示密码不一致错误
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
@@ -128,7 +142,10 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await expect(page.getByText("请输入用户名")).toBeVisible();
});
@@ -154,10 +171,16 @@ test.describe("注册页面", () => {
{ timeout: 15_000 },
);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
const resp = await registerResponse;
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
expect(
resp.ok(),
`注册请求应返回 2xx,实际: ${resp.status()}`,
).toBeTruthy();
// 注册成功后应跳转到登录页或显示成功消息
// 页面应停留在可识别的状态(成功提示或跳转)
@@ -199,14 +222,19 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示错误提示(通过 antd message 或表单错误)
await expect
.poll(
async () => {
// 检查是否有错误消息
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
const hasError = await page
.getByText(/注册失败|已注册|已存在|exists/)
.isVisible();
return hasError ? "error_shown" : "waiting";
},
{ timeout: 10_000 },
@@ -254,7 +282,12 @@ test.describe("注册页面", () => {
// 注册
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
data: {
email,
password: PASSWORD,
username,
display_name: "Reg Auth Test",
},
});
// 登录
@@ -293,6 +326,8 @@ test.describe("注册页面", () => {
// 注册页对已登录用户也可访问(注册页是公开页面)
// 验证页面正常渲染
await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
await expect(
page.locator("button[type='submit']").filter({ hasText: "注册" }),
).toBeVisible();
});
});
+30 -14
View File
@@ -9,7 +9,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -243,8 +248,13 @@ test.describe("订阅套餐页 - 升级交互", () => {
const url = page.url();
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
expect(
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
url.includes("/subscription/upgrade") ||
url.includes("/subscription") ||
(await page
.locator(".ant-modal, [role='dialog']")
.first()
.isVisible()
.catch(() => false)),
).toBeTruthy();
}
});
@@ -538,13 +548,16 @@ test.describe("订阅 - 支付流程", () => {
test("创建支付订单 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-pay-api");
const response = await request.post(`${apiBase}/subscription/create-order`, {
headers,
data: {
plan_id: "pro",
billing_cycle: "monthly",
const response = await request.post(
`${apiBase}/subscription/create-order`,
{
headers,
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
},
});
);
// 创建支付订单可能成功或接口不存在
expect(
@@ -560,12 +573,15 @@ test.describe("订阅 - 支付流程", () => {
});
test("未登录创建订单 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/create-order`, {
data: {
plan_id: "pro",
billing_cycle: "monthly",
const response = await request.post(
`${apiBase}/subscription/create-order`,
{
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
},
});
);
expect([401, 403, 404]).toContain(response.status());
});
});
+4 -1
View File
@@ -178,7 +178,10 @@ test.describe("订阅过期处理", () => {
// 免费用户可能不需要取消,返回 400 或类似错误
if (!response.ok()) {
const data = await response.json();
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
expect(
data.error?.message || data.detail || data.message,
"应返回错误信息",
).toBeTruthy();
}
});
+16 -7
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -326,7 +331,9 @@ test.describe("模板库 - 模板展示", () => {
if (await modal.isVisible({ timeout: 5_000 })) {
await expect(modal).toBeVisible();
// 验证预览内容存在
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
await expect(
modal.locator(".xx-template-modal-title-row"),
).toBeVisible();
}
}
});
@@ -487,7 +494,10 @@ test.describe("模板库 - API 操作", () => {
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
expect(
unfavResp.status() < 500,
"取消收藏请求应返回 2xx 或 4xx",
).toBeTruthy();
});
test("获取模板详情 - 正向", async ({ request }) => {
@@ -515,10 +525,9 @@ test.describe("模板库 - API 操作", () => {
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
const detailResp = await request.get(
`${apiBase}/templates/${created.id}`,
{ headers },
);
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
headers,
});
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(created.id);
+6 -5
View File
@@ -175,10 +175,9 @@ test.describe("认证流程", () => {
},
});
expect(
[400, 422],
"缺少用户名字段应返回 4xx 校验错误",
).toContain(response.status());
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
response.status(),
);
});
// ─── 登录 ────────────────────────────────────────────
@@ -230,7 +229,9 @@ test.describe("认证流程", () => {
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
});
if (response.status() !== 429) break;
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
console.log(
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
);
await new Promise((r) => setTimeout(r, 65_000));
}
+34 -7
View File
@@ -9,7 +9,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -224,7 +229,11 @@ test.describe("标题库 - API 完整操作", () => {
test("编辑标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update");
const titleId = await createTitle(request, headers, Date.now().toString(36));
const titleId = await createTitle(
request,
headers,
Date.now().toString(36),
);
const newName = `更新后的标题 ${Date.now()}`;
const newText = "这是更新后的标题内容";
@@ -256,7 +265,11 @@ test.describe("标题库 - API 完整操作", () => {
test("删除标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-delete");
const titleId = await createTitle(request, headers, Date.now().toString(36));
const titleId = await createTitle(
request,
headers,
Date.now().toString(36),
);
// 删除
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
@@ -279,9 +292,21 @@ test.describe("标题库 - API 完整操作", () => {
const suffix = Date.now().toString(36);
const titles = [
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
{
name: `批量标题 1 ${suffix}`,
text: `内容 1 ${suffix}`,
category: "default",
},
{
name: `批量标题 2 ${suffix}`,
text: `内容 2 ${suffix}`,
category: "种草",
},
{
name: `批量标题 3 ${suffix}`,
text: `内容 3 ${suffix}`,
category: "知识",
},
];
const response = await request.post(`${apiBase}/titles/batch-import`, {
@@ -297,7 +322,9 @@ test.describe("标题库 - API 完整操作", () => {
if (response.ok()) {
const data = await response.json();
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
expect(
Array.isArray(data) || data.success_count !== undefined,
).toBeTruthy();
}
});
+15 -7
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -326,10 +331,9 @@ test.describe("声音克隆 - API 操作", () => {
).toBeTruthy();
// 验证已删除
const getResp = await request.get(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
headers,
});
expect([404, 410]).toContain(getResp.status());
}
// 如果创建失败(比如音频格式问题),测试也通过
@@ -491,11 +495,15 @@ test.describe("声音克隆 - 上传区域", () => {
});
// 尝试点击克隆新音色按钮
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
const cloneBtn = page.getByRole("button", {
name: /克隆新音色|立即克隆|新建/,
});
if (await cloneBtn.isVisible()) {
await cloneBtn.click();
// 弹窗应该出现
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
const modal = page.locator(
".ant-modal, .vc-edit-dialog, [role='dialog']",
);
if (await modal.first().isVisible({ timeout: 5_000 })) {
await expect(modal.first()).toBeVisible();
}
+9 -2
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -157,7 +162,9 @@ test.describe("音色库页面 - 页面加载", () => {
});
// 验证搜索框存在
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
const searchInput = page.locator(
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
);
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
});
});
+81 -4
View File
@@ -5,6 +5,32 @@
import apiClient from "./client";
import { getOrCreateDefaultProject } from "./projects";
/** 素材元数据 */
export interface AssetMetadata {
/** 时长(秒) */
duration?: number;
/** 宽度(像素) */
width?: number;
/** 高度(像素) */
height?: number;
/** 比特率(bps */
bitrate?: number;
/** 编码格式 */
codec?: string;
/** 帧率 */
fps?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 声道数 */
channels?: number;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** 素材分类状态 */
export type AssetClassificationStatus =
"pending" | "processing" | "completed" | "failed";
/** 素材条目 */
export interface AssetItem {
id: string;
@@ -12,12 +38,14 @@ export interface AssetItem {
name: string;
storage_key: string;
mime_type: string;
metadata: Record<string, unknown>;
metadata: AssetMetadata;
file_size?: number;
file_url?: string;
thumbnail_url?: string;
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number;
status?: string;
classification_status?: string | null;
classification_status?: AssetClassificationStatus | null;
quality_score?: number | null;
tag_ids?: string[];
created_at?: string;
@@ -167,7 +195,7 @@ export const createAsset = async (data: {
name: string;
storage_key: string;
mime_type: string;
metadata?: Record<string, unknown>;
metadata?: AssetMetadata;
}): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data);
return response.data;
@@ -176,7 +204,7 @@ export const createAsset = async (data: {
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: Record<string, unknown> },
data: { name?: string; metadata?: AssetMetadata },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data);
return response.data;
@@ -350,3 +378,52 @@ export const getClassificationJob = async (
const response = await apiClient.get(`/classification-jobs/${jobId}`);
return response.data;
};
// ─── 批量操作 ───────────────────────────────────────────────
/** 批量操作结果 */
export interface BatchOperationResult {
succeeded: string[];
failed: string[];
total: number;
success_count: number;
failure_count: number;
}
/** 批量删除素材 */
export const batchDeleteAssets = async (
assetIds: string[],
): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds,
});
return response.data;
};
/** 批量打标签 */
export const batchTagAssets = async (data: {
asset_ids: string[];
tags: string[];
mode: "add" | "replace";
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data);
return response.data;
};
/** 批量改分类 */
export const batchClassifyAssets = async (data: {
asset_ids: string[];
category: string;
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data);
return response.data;
};
/** 批量智能标记 */
export const batchMarkAssets = async (data: {
asset_ids: string[];
smart_view: "recommended" | "caution" | "high_risk";
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data);
return response.data;
};
+70
View File
@@ -0,0 +1,70 @@
/**
* BGM API
* BGM +
*/
import apiClient from "./client";
/* ──────────── 类型 ──────────── */
/** BGM 风格分类 */
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
/** BGM 预设项 */
export interface BgmPreset {
id: string;
name: string;
category: BgmCategory;
/** 音频文件 URL */
url: string;
/** 时长(秒) */
duration: number;
/** 关键词标签 */
tags: string[];
/** 封面图 URL */
cover_url?: string;
}
/** BGM 预设列表查询参数 */
export interface BgmPresetsQuery {
category?: BgmCategory | string;
keyword?: string;
}
/** BGM 混音配置(嵌入剪辑计划) */
export interface BgmMixConfig {
/** 是否启用 BGM */
enabled: boolean;
/** 选中的 BGM ID */
music_id: string;
/** BGM 音量 0-100 */
volume: number;
/** 淡入时长(秒) 0-3 */
fade_in: number;
/** 淡出时长(秒) 0-3 */
fade_out: number;
/** 人声闪避(sidechain */
voice_dodge: boolean;
}
/** 默认 BGM 混音配置 */
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
enabled: false,
music_id: "",
volume: 50,
fade_in: 0.5,
fade_out: 0.5,
voice_dodge: true,
};
/* ──────────── API ──────────── */
/** 获取 BGM 预设列表 */
export const getBgmPresets = async (
params?: BgmPresetsQuery,
): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {};
if (params?.category) searchParams.category = params.category;
if (params?.keyword) searchParams.keyword = params.keyword;
const res = await apiClient.get("/bgm/presets", { params: searchParams });
return res.data?.data ?? res.data ?? [];
};
+2 -1
View File
@@ -129,7 +129,8 @@ apiClient.interceptors.response.use(
const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
+216 -32
View File
@@ -4,6 +4,15 @@
*/
import apiClient from "./client";
import type { AssetItem } from "./assets";
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types";
/* ============================================================
* API Schema
@@ -13,6 +22,107 @@ import type { AssetItem } from "./assets";
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed";
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
ai_auto_select: boolean;
content: string;
font_preset: string;
font_color: string;
font_size: number;
position: string;
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean;
position: string;
font: string;
color: string;
size: number;
animation: string;
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean;
music_id: string;
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string;
text: string;
voice_id: string;
speed: number;
pitch: number;
volume: number;
subtitle_sync: boolean;
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number;
end_time: number;
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string;
duration: number;
}
/** 剪辑计划中的单个片段(config 内部 segments 项) */
export interface EditPlanSegment {
segment_order: number;
duration_min: number;
duration_max: number;
material_type: string;
transition?: SegmentTransitionConfig;
playback_speed?: number;
tts_config?: SegmentTtsConfig;
trim_config?: SegmentTrimConfig;
}
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
export interface EditPlanConfig {
title_config?: TitleConfig;
subtitle_config?: SubtitleConfig;
bgm_config?: BgmConfig;
estimated_duration?: number;
segments?: EditPlanSegment[];
watermark_config?: WatermarkConfig;
intro_outro_config?: IntroOutroConfig;
pip_config?: PipConfig;
filter_config?: FilterConfig;
green_screen_config?: ChromaKeyConfig;
sticker_config?: StickerConfig;
cover_config?: CoverConfig;
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[];
/** 配音 ID */
voice_id?: string;
/** 克隆音色档案 ID */
voice_clone_profile_id?: string;
/** 自定义配音音频 URL */
custom_audio_url?: string;
/** 自定义配音文本 */
custom_text?: string;
/** 视频比例 */
ratio?: string;
/** 视频风格 */
style?: string;
/** 目标时长(秒) */
duration?: number;
/** 是否自动生成字幕 */
auto_subtitles?: boolean;
/** 是否启用 BGM */
bgm?: boolean;
/** 生成数量 */
generate_count?: number;
/** 素材模式 */
material_mode?: string;
}
/** 剪辑计划(后端响应) */
export interface EditPlan {
id: string;
@@ -20,7 +130,7 @@ export interface EditPlan {
name: string;
status: EditPlanStatus;
total_duration: number;
config: Record<string, unknown>;
config: EditPlanConfig;
created_at: string;
updated_at: string;
}
@@ -29,7 +139,7 @@ export interface EditPlan {
export interface CreateEditPlanRequest {
template_id: string;
name: string;
config?: Record<string, unknown>;
config?: EditPlanConfig;
total_duration?: number;
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
source_edit_plan_id?: string;
@@ -38,7 +148,7 @@ export interface CreateEditPlanRequest {
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
config?: Record<string, unknown>;
config?: EditPlanConfig;
total_duration?: number;
status?: EditPlanStatus;
}
@@ -80,6 +190,26 @@ export interface GenerationStatusResponse {
clips: ClipStatusItem[];
}
/** 生成视频详情(对应后端 GeneratedVideoResponse */
export interface GeneratedVideo {
id: string;
project_id?: string;
generation_task_id?: string;
name: string;
file_url: string;
file_size?: number;
duration?: number;
thumbnail_url?: string;
width?: number;
height?: number;
fps?: number;
status: string;
review_status?: string;
download_url?: string;
created_at?: string;
updated_at?: string;
}
/* ============================================================
* AI & 3.09
* ============================================================ */
@@ -100,14 +230,14 @@ export interface AIRecommendClipItem {
transition_effect: string;
asset_id: string;
start_time: number;
config: Record<string, unknown>;
config: EditPlanConfig;
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string;
clips: AIRecommendClipItem[];
config: Record<string, unknown>;
config: EditPlanConfig;
total_duration: number;
confidence: number;
}
@@ -122,7 +252,15 @@ export interface GenerateCoverRequest {
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string;
cover: Record<string, unknown>;
cover: CoverResult;
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string;
asset_id?: string;
frame_time?: number;
thumbnail_url?: string;
}
/* ============================================================
@@ -147,10 +285,27 @@ export interface EditPlanClip {
order: number;
}
/** 转场效果 */
/** 转场效果14 种预设) */
export interface TransitionEffect {
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop";
duration: number; // 转场时长(秒)
/** 播放速度倍率 */
playback_speed?: number;
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
@@ -177,15 +332,30 @@ export interface MediaAsset {
* API
* ============================================================ */
/** 获取剪辑计划列表 */
export async function getEditPlans(params?: {
/** 剪辑计划列表查询参数 */
export interface EditPlanListParams {
page?: number;
page_size?: number;
template_id?: string;
status?: string;
}): Promise<EditPlan[]> {
const response = await apiClient.get("/edit-plans", { params });
return response.data.items || [];
}
/** 剪辑计划列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[];
total: number;
page: number;
page_size: number;
}
/** 获取剪辑计划列表(支持分页和筛选) */
export async function getEditPlans(
params?: EditPlanListParams,
): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
params,
});
return response.data;
}
/** 获取单个剪辑计划 */
@@ -266,6 +436,14 @@ export async function getEditPlanGenerations(
return response.data.items || [];
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(
taskId: string,
): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
return response.data.items || response.data || [];
}
/**
* GET /api/v1/assets?library_id=xxx
* AssetResponse MediaAsset
@@ -297,26 +475,22 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" {
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
const meta = (asset.metadata || {}) as Record<string, unknown>;
const ext = asset as AssetItem & Record<string, unknown>;
// 优先取顶层 duration,其次从 metadata 回退
const metaDuration =
typeof asset.metadata?.duration === "number"
? asset.metadata.duration
: undefined;
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url:
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
duration:
typeof ext.duration === "number"
? ext.duration
: typeof meta.duration === "number"
? (meta.duration as number)
: undefined,
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: (asset.classification_status ??
undefined) as MediaAsset["classification_status"],
classification_status: asset.classification_status ?? undefined,
};
}
@@ -324,17 +498,27 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
*
* ============================================================ */
/** 转场效果选项 */
/** 转场效果选项14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"];
label: string;
icon: string;
}[] = [
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出" },
{ value: "dissolve", label: "溶解" },
{ value: "wipe", label: "擦除" },
{ value: "zoom", label: "缩放" },
{ value: "slide", label: "滑动" },
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
];
/** 素材类型标签 */
+50 -1
View File
@@ -3,6 +3,15 @@
* /api/v1/templates
*/
import apiClient from "./client";
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types";
/* ──────────── 类型定义 ──────────── */
@@ -72,6 +81,20 @@ export interface EditingTemplate {
bgm_config: BgmConfig;
estimated_duration: number;
segments: TemplateSegment[];
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig;
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig;
/** 画中画配置 */
pip_config?: PipConfig;
/** 滤镜调色配置 */
filter_config?: FilterConfig;
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig;
/** 贴纸配置 */
sticker_config?: StickerConfig;
/** 封面配置 */
cover_config?: CoverConfig;
is_active?: boolean;
created_at: string;
updated_at: string;
@@ -95,6 +118,20 @@ export interface SaveTemplatePayload {
bgm_config: BgmConfig;
estimated_duration: number;
segments: Omit<TemplateSegment, "id">[];
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig;
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig;
/** 画中画配置 */
pip_config?: PipConfig;
/** 滤镜调色配置 */
filter_config?: FilterConfig;
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig;
/** 贴纸配置 */
sticker_config?: StickerConfig;
/** 封面配置 */
cover_config?: CoverConfig;
}
/** 使用模板生成请求体 */
@@ -102,11 +139,23 @@ export interface GenerateFromTemplatePayload {
voiceover_duration: number;
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
/** 相关字段名 */
field?: string;
/** 期望值 */
expected?: string | number;
/** 实际值 */
actual?: string | number;
/** 建议值 */
suggested?: string | number;
}
/** 验证/生成响应 */
export interface ValidateWarning {
code: string;
message: string;
details?: Record<string, unknown>;
details?: ValidationWarningDetails;
}
/** 使用模板生成响应 */
+113 -14
View File
@@ -1,8 +1,13 @@
/**
* API
* Phase 1 projectId
* / API
* /products /generation/tasks
*/
import apiClient from "./client";
import { getGenerationTaskResults } from "./editPlans";
import type { GeneratedVideo } from "./editPlans";
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected";
/** 成品条目 */
export interface ProductItem {
@@ -14,33 +19,127 @@ export interface ProductItem {
file_size?: number;
resolution?: string;
status: "processing" | "completed" | "failed";
/** 复核状态 */
review_status?: ReviewStatus;
/** 所属项目 ID */
project_id?: string;
/** 所属项目名称 */
project_name?: string;
/** 查重率(百分比) */
duplicate_rate?: number;
created_at?: string;
updated_at?: string;
}
/** 获取当前用户的所有成品 */
export const getProducts = async (): Promise<ProductItem[]> => {
const response = await apiClient.get("/products");
return response.data.items || response.data || [];
/** 列表查询参数 */
export interface ProductListParams {
page?: number;
page_size?: number;
project_id?: string;
review_status?: ReviewStatus | "all";
}
/** 分页响应 */
export interface ProductListResponse {
items: ProductItem[];
total: number;
page: number;
page_size: number;
}
/** 批量下载任务状态 */
export interface BatchDownloadStatus {
job_id: string;
status: "processing" | "completed" | "failed";
/** 完成后返回的下载 URL */
download_url?: string;
/** 进度百分比 */
progress?: number;
}
/**
* generation task ProductItem
*/
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
return {
id: task.id,
title: task.name || "未命名视频",
video_url: task.file_url,
thumbnail_url: task.thumbnail_url,
duration_seconds: task.duration,
file_size: task.file_size,
resolution:
task.width && task.height ? `${task.width}x${task.height}` : undefined,
status:
task.status === "completed"
? "completed"
: task.status === "failed"
? "failed"
: "processing",
review_status: task.review_status as ReviewStatus | undefined,
project_id: task.project_id,
created_at: task.created_at,
updated_at: task.updated_at,
};
}
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
export const getProducts = async (
params?: ProductListParams,
): Promise<ProductItem[]> => {
const response = await apiClient.get("/generation/tasks", { params });
const tasks = response.data.items || response.data || [];
return tasks.map(mapTaskToProductItem);
};
/** 获取单个成品详情 */
/** 获取单个成品详情 — 通过 task ID 获取结果 */
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/products/${productId}`);
return response.data;
const response = await apiClient.get(`/generation/tasks/${productId}`);
return mapTaskToProductItem(response.data);
};
/** 删除成品 */
/** 删除成品 — 删除 generation task */
export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/products/${productId}`);
await apiClient.delete(`/generation/tasks/${productId}`);
};
/** 获取成品下载链接 */
/** 获取成品下载链接 — 从 generation task results 获取 */
export const getProductDownloadUrl = async (
productId: string,
): Promise<{ url: string; expires_at: string }> => {
const response = await apiClient.get(`/products/${productId}/download-url`);
return response.data;
const videos = await getGenerationTaskResults(productId);
const video = videos[0];
if (!video?.download_url) throw new Error("下载链接不可用");
return { url: video.download_url, expires_at: "" };
};
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /generation/tasks/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId);
return { ...product, review_status: status };
};
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (
videoIds: string[],
): Promise<{ job_id: string }> => {
// 后端暂无 /generation/tasks/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
return { job_id: `mock-${Date.now()}` };
};
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (
jobId: string,
): Promise<BatchDownloadStatus> => {
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
return { job_id: jobId, status: "processing", progress: 0 };
};
+58 -12
View File
@@ -1,31 +1,67 @@
/**
* API
* A PR #109
* - POST /api/v1/generation/tasks template_id + asset_ids
* - GET /api/v1/tasks project
* - POST /api/v1/tasks/{task_id}/retry
* API
* - POST /api/v1/generation/tasks
* - GET /api/v1/tasks /
* - GET /api/v1/tasks/{task_id} error_info
* - POST /api/v1/tasks/{task_id}/retry
*/
import apiClient from "./client";
/* ──────────── 类型定义 ──────────── */
/** 任务状态 */
export type TaskStatus =
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
/** 任务类型 */
export type TaskType = "ingest" | "generation" | string;
/** 错误详情 */
export interface TaskErrorInfo {
error_type: string;
error_message: string;
failed_step: string;
stack_trace?: string;
}
/** 任务条目(对应用户级 UserTaskResponse */
export interface TaskItem {
id: string;
task_type: "ingest" | "generation" | string;
task_type: TaskType;
project_id: string;
template_id: string;
status: string;
template_id?: string;
status: TaskStatus;
progress: number;
current_step: string;
error_message: string;
user_message: string;
retryable: boolean;
source_id: string;
/** 错误详情(失败任务) */
error_info?: TaskErrorInfo;
/** 耗时(秒) */
duration_seconds?: number;
created_at?: string | null;
updated_at?: string | null;
}
/** 任务列表查询参数 */
export interface TaskListParams {
page?: number;
page_size?: number;
status?: TaskStatus | "all";
task_type?: TaskType | "all";
}
/** 任务列表分页响应 */
export interface TaskListResponse {
items: TaskItem[];
total: number;
page: number;
page_size: number;
}
/** 创建生成任务请求参数 */
export interface CreateGenerationTaskRequest {
template_id: string;
@@ -64,13 +100,23 @@ export const createGenerationTask = async (
return data;
};
/** 获取当前用户的所有任务(跨 project */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks");
return data.items || [];
/** 获取任务列表(支持分页和筛选 */
export const getTasks = async (
params?: TaskListParams,
): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params,
});
return data;
};
/** 获取单个任务详情(用于轮询进度 */
/** 获取当前用户的所有任务(兼容旧接口,跨 project */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks");
return data.items || data || [];
};
/** 获取单个任务详情(含 error_info */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`);
return data;
+133 -4
View File
@@ -1,26 +1,112 @@
/**
* API
* Phase 1
*
* - GET /api/v1/templates /
* - GET /api/v1/templates/{id}
* - POST /api/v1/templates/{id}/copy
* - POST /api/v1/templates/{id}/generate
* - POST /api/v1/templates/{id}/toggle-favorite /
*/
import apiClient from "./client";
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
import type { EditPlanConfig } from "./editPlans";
/** 模板条目 */
/* ──────────── 类型定义 ──────────── */
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string;
name: string;
description: string;
category: string;
tags?: string[];
target_duration: number;
clip_count: number;
/** 使用次数 */
usage_count?: number;
thumbnail_url?: string;
preview_url?: string;
is_active: boolean;
is_favorite?: boolean;
/** 素材规则(片段配置) */
segments?: TemplateSegment[];
/** 字幕样式 */
subtitle_config?: SubtitleConfig;
/** BGM 配置 */
bgm_config?: BgmConfig;
/** 标题配置 */
title_config?: TitleConfig;
/** 视频比例 */
aspect_ratio?: string;
created_at?: string;
updated_at?: string;
}
/** 获取全局模板列表 */
export const getTemplates = async (): Promise<TemplateItem[]> => {
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string;
segment_order: number;
duration_min: number;
duration_max: number;
material_type: string | null;
description?: string;
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number;
page_size?: number;
category?: string;
tags?: string;
keyword?: string;
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long";
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[];
total: number;
page: number;
page_size: number;
}
/** 从模板生成剪辑计划请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[];
name?: string;
config?: EditPlanConfig;
}
/** 从模板生成剪辑计划响应 */
export interface GenerateFromTemplateResponse {
plan_id: string;
template_id: string;
status: string;
name: string;
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string;
name: string;
source_template_id: string;
}
/* ──────────── API 函数 ──────────── */
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (
params?: TemplateListParams,
): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
});
return data;
};
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates");
return response.data.items || response.data || [];
};
@@ -42,3 +128,46 @@ export const toggleFavoriteTemplate = async (
);
return response.data;
};
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (
templateId: string,
): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(
`/templates/${templateId}/copy`,
);
return response.data;
};
/** 从模板生成剪辑计划 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
);
return response.data;
};
/* ──────────── 常量 ──────────── */
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
];
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
];
+63 -2
View File
@@ -8,6 +8,18 @@ import apiClient from "./client";
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 语言 */
language?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string;
@@ -18,7 +30,7 @@ export interface TTSSynthesizeRequest {
voice_model?: string;
voice_clone_profile_id?: string;
format?: string;
metadata?: Record<string, unknown>;
metadata?: TTSMetadata;
}
/** TTS 合成创建响应 */
@@ -49,7 +61,7 @@ export interface TTSJob {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: Record<string, unknown> | null;
metadata_: TTSMetadata | null;
created_at: string;
updated_at: string;
}
@@ -140,3 +152,52 @@ export const saveTtsToLibrary = async (
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`);
};
/* ── 音色列表 ──────────────────────────────────── */
/** TTS 音色 */
export interface TTSVoice {
id: string;
name: string;
/** 音色分类标签:male/female/young/service/news/emotion */
category?: string;
/** 语言 */
language?: string;
/** 试听 URL */
preview_url?: string;
/** 描述 */
description?: string;
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
return response.data;
};
/* ── TTS 试听 ──────────────────────────────────── */
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string;
voice_id: string;
speed?: number;
pitch?: number;
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string;
duration?: number;
}
/** TTS 试听 */
export const previewTts = async (
data: TTSPreviewRequest,
): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>(
"/tts/preview",
data,
);
return response.data;
};
+14 -2
View File
@@ -36,6 +36,18 @@ export interface CreateVoiceCloneRequest {
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 音色 ID(克隆完成后分配) */
voice_id?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string;
@@ -51,7 +63,7 @@ export interface VoiceCloneProfile {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: Record<string, unknown> | null;
metadata_: VoiceCloneMetadata | null;
created_at: string;
updated_at: string;
}
@@ -80,7 +92,7 @@ export interface CreateVoiceCloneRequestFull {
language?: string;
gender?: string;
max_retries?: number;
metadata_?: Record<string, unknown>;
metadata_?: VoiceCloneMetadata;
}
/* ── 辅助函数 ─────────────────────────────────────────── */
-3
View File
@@ -384,7 +384,6 @@
border-bottom: 1px solid var(--border-light) !important;
}
/* ============================================================
响应式
============================================================ */
@@ -409,7 +408,6 @@
.xx-modal .ant-modal-header {
padding: var(--space-md) !important;
}
}
@media (max-width: 480px) {
@@ -423,7 +421,6 @@
}
}
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
+25
View File
@@ -17,6 +17,7 @@ import {
ScanOutlined,
ControlOutlined,
CrownOutlined,
UnorderedListOutlined,
} from "@ant-design/icons";
/** 导航项类型 */
@@ -80,6 +81,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/my-templates",
icon: React.createElement(FolderOutlined),
},
{
key: "edit-plans",
label: "剪辑计划",
path: "/app/edit-plans",
icon: React.createElement(UnorderedListOutlined),
},
{
key: "generate",
label: "一键生成",
@@ -104,6 +111,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/duplication",
icon: React.createElement(ScanOutlined),
},
{
key: "tasks",
label: "任务中心",
path: "/app/tasks",
icon: React.createElement(UnorderedListOutlined),
},
];
/** 侧边栏导航分组(Sidebar 分组列表使用) */
@@ -129,6 +142,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/editing-planner",
icon: React.createElement(EditOutlined),
},
{
key: "edit-plans",
label: "剪辑计划",
path: "/app/edit-plans",
icon: React.createElement(UnorderedListOutlined),
},
],
},
{
@@ -181,6 +200,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/history",
icon: React.createElement(HistoryOutlined),
},
{
key: "tasks",
label: "任务中心",
path: "/app/tasks",
icon: React.createElement(UnorderedListOutlined),
},
{
key: "duplication",
label: "查重",
+417 -12
View File
@@ -4,7 +4,17 @@
* 使 useQuery APIapi/assets.ts
*/
import React, { useMemo, useState } from "react";
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
import {
Upload,
Modal as AntModal,
message,
Popconfirm,
Drawer,
Tag,
Input as AntInput,
Radio,
Select as AntSelect,
} from "antd";
import {
PlusOutlined,
SearchOutlined,
@@ -17,6 +27,11 @@ import {
ExperimentOutlined,
LoadingOutlined,
ExclamationCircleOutlined,
TagsOutlined,
FolderOutlined,
ThunderboltOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -27,8 +42,13 @@ import {
deleteAsset,
uploadAssetDirect,
getAssetDiagnosis,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
type BatchOperationResult,
} from "@/api/assets";
import { Button, Input, Select } from "@/components/ui";
import "./assets.css";
@@ -407,6 +427,33 @@ const AssetLibrary: React.FC = () => {
/* 诊断中状态 — 记录正在诊断的素材 ID */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
/* ── 批量操作弹窗状态 ── */
const [tagModalOpen, setTagModalOpen] = useState(false);
const [classifyModalOpen, setClassifyModalOpen] = useState(false);
const [markModalOpen, setMarkModalOpen] = useState(false);
const [resultDrawerOpen, setResultDrawerOpen] = useState(false);
/* 批量打标签 */
const [batchTagInput, setBatchTagInput] = useState("");
const [batchTags, setBatchTags] = useState<string[]>([]);
const [tagMode, setTagMode] = useState<"add" | "replace">("add");
/* 批量改分类 */
const [batchCategory, setBatchCategory] = useState("");
/* 批量智能标记 */
const [batchSmartView, setBatchSmartView] = useState<
"recommended" | "caution" | "high_risk"
>("recommended");
/* 操作结果 */
const [operationResult, setOperationResult] =
useState<BatchOperationResult | null>(null);
const [operationTitle, setOperationTitle] = useState("");
/* 批量操作 loading */
const [batchLoading, setBatchLoading] = useState(false);
/* 派生数据 */
const filteredAssets = useMemo(() => {
let list = assets;
@@ -563,19 +610,152 @@ const AssetLibrary: React.FC = () => {
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds);
let successCount = 0;
for (const id of ids) {
try {
await deleteAsset(id);
successCount++;
} catch {
// 忽略单个失败
setBatchLoading(true);
try {
const result = await batchDeleteAssets(ids);
setOperationResult(result);
setOperationTitle("批量删除");
setResultDrawerOpen(true);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`);
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量删除失败,请重试");
} finally {
setBatchLoading(false);
}
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
setSelectedIds(new Set());
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
};
/* 批量打标签 */
const handleBatchTag = async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签");
return;
}
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
});
setOperationResult(result);
setOperationTitle("批量打标签");
setResultDrawerOpen(true);
setTagModalOpen(false);
setBatchTags([]);
setBatchTagInput("");
setTagMode("add");
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`);
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量打标签失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 批量改分类 */
const handleBatchClassify = async () => {
if (!batchCategory) {
message.warning("请选择分类");
return;
}
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
});
setOperationResult(result);
setOperationTitle("批量改分类");
setResultDrawerOpen(true);
setClassifyModalOpen(false);
setBatchCategory("");
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材改为「${batchCategory}`,
);
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量改分类失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 批量智能标记 */
const handleBatchMark = async () => {
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
});
setOperationResult(result);
setOperationTitle("批量智能标记");
setResultDrawerOpen(true);
setMarkModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
const labelMap = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
};
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}`,
);
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量智能标记失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 标签输入处理 */
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault();
const tag = batchTagInput.trim();
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag]);
}
setBatchTagInput("");
}
};
const removeBatchTag = (tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag));
};
// ── Loading 状态 ──
@@ -769,6 +949,30 @@ const AssetLibrary: React.FC = () => {
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<TagsOutlined />}
onClick={() => setTagModalOpen(true)}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<FolderOutlined />}
onClick={() => setClassifyModalOpen(true)}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<ThunderboltOutlined />}
onClick={() => setMarkModalOpen(true)}
>
</Button>
<Popconfirm
title={`确定删除 ${selectedIds.size} 个素材?`}
onConfirm={handleBatchDelete}
@@ -898,6 +1102,207 @@ const AssetLibrary: React.FC = () => {
</div>
)}
</AntModal>
{/* ─── 批量打标签弹窗 ─── */}
<AntModal
title={`批量打标签(${selectedIds.size} 个素材)`}
open={tagModalOpen}
onCancel={() => {
setTagModalOpen(false);
setBatchTags([]);
setBatchTagInput("");
}}
onOk={handleBatchTag}
confirmLoading={batchLoading}
okText="确认打标签"
cancelText="取消"
>
<div className="xx-batch-tag-modal">
<div className="xx-batch-tag-mode">
<span className="xx-batch-tag-mode-label"></span>
<Radio.Group
value={tagMode}
onChange={(e) => setTagMode(e.target.value)}
>
<Radio value="add"></Radio>
<Radio value="replace"></Radio>
</Radio.Group>
</div>
<div className="xx-batch-tag-input-row">
<AntInput
placeholder="输入标签后按 Enter 添加"
value={batchTagInput}
onChange={(e) => setBatchTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
style={{ flex: 1 }}
/>
</div>
{batchTags.length > 0 && (
<div className="xx-batch-tag-list">
{batchTags.map((tag) => (
<Tag
key={tag}
closable
onClose={() => removeBatchTag(tag)}
color="blue"
>
{tag}
</Tag>
))}
</div>
)}
{tagMode === "replace" && batchTags.length > 0 && (
<div className="xx-batch-tag-warning">
<ExclamationCircleOutlined />
</div>
)}
</div>
</AntModal>
{/* ─── 批量改分类弹窗 ─── */}
<AntModal
title={`批量改分类(${selectedIds.size} 个素材)`}
open={classifyModalOpen}
onCancel={() => {
setClassifyModalOpen(false);
setBatchCategory("");
}}
onOk={handleBatchClassify}
confirmLoading={batchLoading}
okText="确认修改"
cancelText="取消"
>
<div className="xx-batch-classify-modal">
<p className="xx-batch-classify-hint">
{selectedIds.size}
</p>
<AntSelect
value={batchCategory || undefined}
onChange={(v) => setBatchCategory(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
options={[
{ value: "person", label: "人物" },
{ value: "scenic", label: "风景" },
{ value: "product", label: "产品" },
{ value: "food", label: "美食" },
{ value: "animal", label: "动物" },
{ value: "architecture", label: "建筑" },
{ value: "other", label: "其他" },
]}
/>
</div>
</AntModal>
{/* ─── 批量智能标记弹窗 ─── */}
<AntModal
title={`批量智能标记(${selectedIds.size} 个素材)`}
open={markModalOpen}
onCancel={() => setMarkModalOpen(false)}
onOk={handleBatchMark}
confirmLoading={batchLoading}
okText="确认标记"
cancelText="取消"
>
<div className="xx-batch-mark-modal">
<p className="xx-batch-mark-hint">
{selectedIds.size}
</p>
<Radio.Group
value={batchSmartView}
onChange={(e) => setBatchSmartView(e.target.value)}
className="xx-batch-mark-options"
>
<div className="xx-batch-mark-option">
<Radio value="recommended">
<Tag color="success"></Tag>
<span className="xx-batch-mark-desc">
</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="caution">
<Tag color="warning"></Tag>
<span className="xx-batch-mark-desc">
使
</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="high_risk">
<Tag color="error"></Tag>
<span className="xx-batch-mark-desc">
使
</span>
</Radio>
</div>
</Radio.Group>
</div>
</AntModal>
{/* ─── 操作结果 Drawer ─── */}
<Drawer
title={`${operationTitle} — 操作结果`}
open={resultDrawerOpen}
onClose={() => {
setResultDrawerOpen(false);
setOperationResult(null);
}}
width={420}
>
{operationResult && (
<div className="xx-batch-result">
<div className="xx-batch-result-summary">
<div className="xx-batch-result-stat">
<span className="xx-batch-result-total">
{operationResult.total}
</span>
</div>
<div className="xx-batch-result-stat success">
<CheckCircleOutlined />
<span> {operationResult.success_count} </span>
</div>
{operationResult.failure_count > 0 && (
<div className="xx-batch-result-stat fail">
<CloseCircleOutlined />
<span> {operationResult.failure_count} </span>
</div>
)}
</div>
{operationResult.succeeded.length > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title success">
<CheckCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{operationResult.succeeded.map((id) => (
<div key={id} className="xx-batch-result-id">
{id}
</div>
))}
</div>
</div>
)}
{operationResult.failed.length > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title fail">
<CloseCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{operationResult.failed.map((id) => (
<div key={id} className="xx-batch-result-id fail">
{id}
</div>
))}
</div>
</div>
)}
</div>
)}
</Drawer>
</div>
);
};
+172
View File
@@ -653,3 +653,175 @@
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
/* ─── 批量打标签弹窗 ─── */
.xx-batch-tag-modal {
display: flex;
flex-direction: column;
gap: 16px;
}
.xx-batch-tag-mode {
display: flex;
align-items: center;
gap: 8px;
}
.xx-batch-tag-mode-label {
font-size: 14px;
color: var(--text-primary, #111827);
font-weight: 500;
}
.xx-batch-tag-input-row {
display: flex;
gap: 8px;
}
.xx-batch-tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.xx-batch-tag-warning {
padding: 10px 12px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: var(--radius-md, 8px);
color: #c2410c;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
/* ─── 批量改分类弹窗 ─── */
.xx-batch-classify-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-classify-hint {
font-size: 14px;
color: var(--text-secondary, #6b7280);
margin: 0;
}
/* ─── 批量智能标记弹窗 ─── */
.xx-batch-mark-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-mark-hint {
font-size: 14px;
color: var(--text-secondary, #6b7280);
margin: 0;
}
.xx-batch-mark-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-mark-option {
display: flex;
flex-direction: column;
}
.xx-batch-mark-desc {
margin-left: 8px;
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
/* ─── 操作结果 Drawer ─── */
.xx-batch-result {
display: flex;
flex-direction: column;
gap: 20px;
}
.xx-batch-result-summary {
display: flex;
gap: 16px;
padding: 16px;
background: var(--bg-secondary, #f9fafb);
border-radius: var(--radius-md, 8px);
}
.xx-batch-result-stat {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-primary, #111827);
}
.xx-batch-result-stat.success {
color: #059669;
}
.xx-batch-result-stat.fail {
color: #dc2626;
}
.xx-batch-result-total {
font-weight: 600;
}
.xx-batch-result-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-batch-result-section-title {
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin: 0;
}
.xx-batch-result-section-title.success {
color: #059669;
}
.xx-batch-result-section-title.fail {
color: #dc2626;
}
.xx-batch-result-ids {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 300px;
overflow-y: auto;
}
.xx-batch-result-id {
padding: 6px 10px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: 12px;
font-family: monospace;
color: var(--text-secondary, #6b7280);
word-break: break-all;
}
.xx-batch-result-id.fail {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
+433
View File
@@ -0,0 +1,433 @@
/**
*
*
*/
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Table,
Tabs,
Select,
Tag,
Button,
message,
Popconfirm,
Tooltip,
} from "antd";
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
EditOutlined,
DeleteOutlined,
FileTextOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getEditPlans,
deleteEditPlan,
generateEditPlan,
type EditPlan,
type EditPlanStatus,
type EditPlanListParams,
} from "@/api/editPlans";
import { getTemplatesList, type TemplateItem } from "@/api/templates";
import "./edit-plans.css";
/* ──────────── 常量 ──────────── */
/** 状态 Tab 配置 */
const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "draft", label: "草稿" },
{ key: "editing", label: "编辑中" },
{ key: "rendering", label: "渲染中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
];
/** 状态标签配置 */
const STATUS_CONFIG: Record<
EditPlanStatus,
{ label: string; color: string; icon: React.ReactNode }
> = {
draft: {
label: "草稿",
color: "default",
icon: <FileTextOutlined />,
},
editing: {
label: "编辑中",
color: "processing",
icon: <EditOutlined />,
},
rendering: {
label: "渲染中",
color: "warning",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
};
/* ──────────── 工具函数 ──────────── */
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "-";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/** 格式化时间 */
const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
/* ──────────── 主组件 ──────────── */
export default function EditPlans() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// 筛选状态
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">(
"all",
);
const [templateFilter, setTemplateFilter] = useState<string>("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// 查询参数
const queryParams: EditPlanListParams = {
page,
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(templateFilter !== "all" && { template_id: templateFilter }),
};
// 获取剪辑计划列表
const {
data: planData,
isLoading,
error,
} = useQuery({
queryKey: ["edit-plans", queryParams],
queryFn: () => getEditPlans(queryParams),
refetchInterval: (query) => {
// 有进行中的计划时自动刷新
const plans = query.state.data?.items ?? [];
const hasRunning = plans.some(
(p) => p.status === "rendering" || p.status === "editing",
);
return hasRunning ? 5000 : false;
},
});
// 获取模板列表(用于筛选下拉)
const { data: templates } = useQuery({
queryKey: ["templates-list-simple"],
queryFn: getTemplatesList,
});
const plans = planData?.items ?? [];
const total = planData?.total ?? 0;
// 模板名称映射
const templateNameMap = new Map<string, string>();
(templates ?? []).forEach((t: TemplateItem) => {
templateNameMap.set(t.id, t.name);
});
// 删除计划
const deleteMutation = useMutation({
mutationFn: deleteEditPlan,
onSuccess: () => {
message.success("剪辑计划已删除");
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
},
onError: () => {
message.error("删除失败,请稍后重试");
},
});
// 重新生成
const regenerateMutation = useMutation({
mutationFn: generateEditPlan,
onSuccess: () => {
message.success("已重新提交生成");
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
},
onError: () => {
message.error("重新生成失败,请稍后重试");
},
});
// 跳转到剪辑编辑器
const handleEdit = useCallback(
(plan: EditPlan) => {
navigate(`/app/editing-planner?planId=${plan.id}`);
},
[navigate],
);
// 表格列定义
const columns: ColumnsType<EditPlan> = [
{
title: "计划名称",
dataIndex: "name",
key: "name",
width: 240,
ellipsis: true,
render: (name: string, record: EditPlan) => (
<Tooltip title={name}>
<span className="plan-name" onClick={() => handleEdit(record)}>
{name}
</span>
</Tooltip>
),
},
{
title: "模板",
dataIndex: "template_id",
key: "template_id",
width: 140,
ellipsis: true,
render: (templateId: string) => {
const name = templateNameMap.get(templateId);
return (
<Tag color="blue" className="plan-template-tag">
{name || templateId.slice(0, 8)}
</Tag>
);
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: EditPlanStatus) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
};
return (
<Tag
color={config.color}
icon={config.icon}
className="plan-status-tag"
>
{config.label}
</Tag>
);
},
},
{
title: "时长",
dataIndex: "total_duration",
key: "total_duration",
width: 100,
render: (seconds: number) => (
<span className="plan-duration">{formatDuration(seconds)}</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 130,
render: (time: string) => (
<span className="plan-time">{formatTime(time)}</span>
),
},
{
title: "更新时间",
dataIndex: "updated_at",
key: "updated_at",
width: 130,
render: (time: string) => (
<span className="plan-time">{formatTime(time)}</span>
),
},
{
title: "操作",
key: "action",
width: 180,
fixed: "right",
render: (_: unknown, record: EditPlan) => (
<div className="plan-actions">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
className="plan-action-btn"
>
</Button>
{(record.status === "failed" || record.status === "completed") && (
<Popconfirm
title="确认重新生成"
description="确定要重新生成这个剪辑计划吗?"
onConfirm={() => regenerateMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<ThunderboltOutlined />}
loading={regenerateMutation.isPending}
className="plan-action-btn plan-regenerate-btn"
>
</Button>
</Popconfirm>
)}
<Popconfirm
title="确认删除"
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
onConfirm={() => deleteMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
className="plan-action-btn"
>
</Button>
</Popconfirm>
</div>
),
},
];
// 错误处理
if (error) {
return (
<div className="edit-plans-page">
<div className="edit-plans-error">
<CloseCircleOutlined />
<p></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return (
<div className="edit-plans-page">
{/* 页面标题 */}
<div className="edit-plans-header">
<div className="edit-plans-header-text">
<h2></h2>
<p></p>
</div>
<Button type="primary" onClick={() => navigate("/app/templates")}>
</Button>
</div>
{/* 筛选栏 */}
<div className="edit-plans-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={(key) => {
setStatusFilter(key as EditPlanStatus | "all");
setPage(1);
}}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="edit-plans-status-tabs"
/>
{/* 模板筛选 */}
<Select
value={templateFilter}
onChange={(value) => {
setTemplateFilter(value);
setPage(1);
}}
options={[
{ value: "all", label: "全部模板" },
...(templates ?? []).map((t: TemplateItem) => ({
value: t.id,
label: t.name,
})),
]}
style={{ minWidth: 180 }}
placeholder="选择模板"
className="edit-plans-template-filter"
/>
</div>
{/* 计划表格 */}
<Table
columns={columns}
dataSource={plans}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 900 }}
className="edit-plans-table"
locale={{
emptyText: (
<div className="edit-plans-empty">
<ClockCircleOutlined />
<p></p>
<Button
type="primary"
style={{ marginTop: 12 }}
onClick={() => navigate("/app/templates")}
>
</Button>
</div>
),
}}
/>
</div>
);
}
@@ -0,0 +1,260 @@
/**
* 剪辑计划管理页面样式
*/
/* ── 页面容器 ──────────────────────────────────────────── */
.edit-plans-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
/* ── 页面头部 ──────────────────────────────────────────── */
.edit-plans-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
}
.edit-plans-header-text h2 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.edit-plans-header-text p {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 筛选栏 ────────────────────────────────────────────── */
.edit-plans-filters {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.edit-plans-status-tabs {
flex: 1;
}
.edit-plans-status-tabs .ant-tabs-nav {
margin-bottom: 0 !important;
}
.edit-plans-status-tabs .ant-tabs-tab {
padding: 8px 16px !important;
font-size: 14px;
}
.edit-plans-status-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--primary-500, #6366f1) !important;
font-weight: 500;
}
.edit-plans-status-tabs .ant-tabs-ink-bar {
background: var(--primary-500, #6366f1) !important;
}
.edit-plans-template-filter {
min-width: 180px;
}
/* ── 表格 ──────────────────────────────────────────────── */
.edit-plans-table {
background: var(--bg-surface, #fff);
border-radius: var(--radius-lg, 12px);
overflow: hidden;
border: 1px solid var(--border-primary, #e2e8f0);
}
.edit-plans-table .ant-table {
font-size: 14px;
}
.edit-plans-table .ant-table-thead > tr > th {
background: var(--bg-tertiary, #f8fafc) !important;
border-bottom: 1px solid var(--border-primary, #e2e8f0);
font-weight: 500;
color: var(--text-secondary, #64748b);
font-size: 13px;
padding: 12px 16px;
}
.edit-plans-table .ant-table-tbody > tr > td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-light, #f1f5f9);
}
.edit-plans-table .ant-table-tbody > tr:hover > td {
background: var(--bg-hover, #f8fafc) !important;
}
/* ── 计划名称 ──────────────────────────────────────────── */
.plan-name {
font-weight: 500;
color: var(--text-primary, #1e293b);
cursor: pointer;
transition: color 0.2s;
}
.plan-name:hover {
color: var(--primary-500, #6366f1);
}
/* ── 状态标签 ──────────────────────────────────────────── */
.plan-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
.plan-status-tag.ant-tag-default {
background: #f1f5f9;
color: #64748b;
border-color: transparent;
}
.plan-status-tag.ant-tag-processing {
background: #eff6ff;
color: #2563eb;
border-color: transparent;
}
.plan-status-tag.ant-tag-success {
background: #f0fdf4;
color: #16a34a;
border-color: transparent;
}
.plan-status-tag.ant-tag-error {
background: #fef2f2;
color: #dc2626;
border-color: transparent;
}
.plan-status-tag.ant-tag-warning {
background: #fffbeb;
color: #d97706;
border-color: transparent;
}
/* ── 时长 ──────────────────────────────────────────────── */
.plan-duration {
font-variant-numeric: tabular-nums;
color: var(--text-secondary, #64748b);
}
/* ── 时间 ──────────────────────────────────────────────── */
.plan-time {
color: var(--text-secondary, #64748b);
font-size: 13px;
}
/* ── 操作按钮 ──────────────────────────────────────────── */
.plan-actions {
display: flex;
gap: 4px;
}
.plan-action-btn {
padding: 4px 8px !important;
font-size: 13px !important;
}
.plan-action-btn.ant-btn-link {
color: var(--primary-500, #6366f1);
}
.plan-action-btn.ant-btn-link:hover {
color: var(--primary-600, #4f46e5);
}
.plan-regenerate-btn {
color: var(--primary-500, #6366f1) !important;
}
.plan-regenerate-btn:hover {
color: var(--primary-600, #4f46e5) !important;
}
/* ── 空状态 ────────────────────────────────────────────── */
.edit-plans-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.edit-plans-empty .anticon {
font-size: 48px;
color: var(--text-disabled, #cbd5e1);
margin-bottom: 16px;
}
.edit-plans-empty p {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 错误状态 ──────────────────────────────────────────── */
.edit-plans-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: var(--bg-surface, #fff);
border-radius: var(--radius-lg, 12px);
border: 1px solid var(--border-primary, #e2e8f0);
}
.edit-plans-error .anticon {
font-size: 48px;
color: #ef4444;
margin-bottom: 16px;
}
.edit-plans-error p {
margin: 0 0 16px;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 响应式 ────────────────────────────────────────────── */
@media (max-width: 768px) {
.edit-plans-page {
padding: 16px;
}
.edit-plans-header {
flex-direction: column;
gap: 12px;
}
.edit-plans-filters {
flex-direction: column;
align-items: stretch;
}
.edit-plans-status-tabs {
width: 100%;
}
.edit-plans-template-filter {
width: 100%;
}
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
* V8 1:1
* (42px) (48px) (40px)
*/
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { message } from "antd";
import { useQuery } from "@tanstack/react-query";
@@ -18,7 +18,6 @@ import {
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
generateFromTemplate,
MODE_LABELS,
} from "@/api/editingPlanner";
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
@@ -28,9 +27,34 @@ import {
generateCover,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type { TaskItem } from "@/api/tasks";
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
import type { ClipData, ClipType } from "./types";
import type {
ClipData,
ClipType,
TransitionConfig,
SpeedConfig,
TtsConfig,
TrimConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
TitleSettings,
} from "./types";
import {
DEFAULT_TRANSITION,
DEFAULT_SPEED,
DEFAULT_TTS_CONFIG,
DEFAULT_WATERMARK,
DEFAULT_INTRO_OUTRO,
DEFAULT_PIP_CONFIG,
DEFAULT_FILTER_CONFIG,
DEFAULT_CHROMA_KEY_CONFIG,
DEFAULT_STICKER_CONFIG,
DEFAULT_COVER_CONFIG,
} from "./types";
import {
ensureDefaultLibrary,
getAssetsByKind,
@@ -42,10 +66,23 @@ import MediaPanel from "./components/MediaPanel";
import PreviewPlayer from "./components/PreviewPlayer";
import TimelinePanel from "./components/TimelinePanel";
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
import BgmSelector from "./components/BgmSelector";
import SubtitleStylePanel from "./components/SubtitleStylePanel";
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
import TransitionSelector from "./components/TransitionSelector";
import SpeedPanel from "./components/SpeedPanel";
import TtsPanel from "./components/TtsPanel";
import WatermarkPanel from "./components/WatermarkPanel";
import IntroOutroPanel from "./components/IntroOutroPanel";
import PipConfigPanel from "./components/PipConfigPanel";
import FilterPanel from "./components/FilterPanel";
import GreenScreenPanel from "./components/GreenScreenPanel";
import StickerPanel from "./components/StickerPanel";
import CoverSelector from "./components/CoverSelector";
import SaveModal from "./components/SaveModal";
import GenerationProgressModal from "./components/GenerationProgressModal";
import type { GenPhase } from "./components/GenerationProgressModal";
import GenerationHistoryModal from "./components/GenerationHistoryModal";
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm";
import "./EditingPlanner.css";
/* ──────────── 常量 ──────────── */
@@ -107,7 +144,7 @@ const EditingPlanner: React.FC = () => {
const [searchQuery, setSearchQuery] = useState("");
/* ── 标题/字幕/BGM 设置 ── */
const [titleSettings, setTitleSettings] = useState({
const [titleSettings, setTitleSettings] = useState<TitleSettings>({
aiAutoSelect: false,
title: "",
position: "top",
@@ -120,17 +157,72 @@ const EditingPlanner: React.FC = () => {
color: "#ffffff",
});
const [subtitleSettings, setSubtitleSettings] = useState({
enabled: true,
position: "bottom",
font: "思源黑体",
size: 16,
animation: "none",
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>(
{
...DEFAULT_SUBTITLE_STYLE,
},
);
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
...DEFAULT_BGM_MIX_CONFIG,
});
const [bgmSettings, setBgmSettings] = useState({
music: "none",
/* ── Drawer 开关 ── */
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false);
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false);
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false);
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false);
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
const [transitionTargetClipId, setTransitionTargetClipId] = useState<
string | null
>(null);
/** 当前正在调速的片段 ID */
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(
null,
);
/** TTS 配音面板是否打开 */
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false);
/** 当前正在配置 TTS 的片段 ID */
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null);
/* ── 水印 / 片头片尾 ── */
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
...DEFAULT_WATERMARK,
});
const [introOutroSettings, setIntroOutroSettings] =
useState<IntroOutroConfig>({ ...DEFAULT_INTRO_OUTRO });
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false);
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false);
/* ── 画中画 ── */
const [pipSettings, setPipSettings] = useState<PipConfig>({
...DEFAULT_PIP_CONFIG,
});
const [pipDrawerOpen, setPipDrawerOpen] = useState(false);
/* ── 滤镜调色 ── */
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
...DEFAULT_FILTER_CONFIG,
});
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
/* ── 绿幕抠像 ── */
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
...DEFAULT_CHROMA_KEY_CONFIG,
});
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false);
/* ── 贴纸 ── */
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
...DEFAULT_STICKER_CONFIG,
});
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false);
/* ── 封面 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
...DEFAULT_COVER_CONFIG,
});
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
/* ── 保存弹窗 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
@@ -139,15 +231,6 @@ const EditingPlanner: React.FC = () => {
const [draftTags, setDraftTags] = useState("");
const [saveLoading, setSaveLoading] = useState(false);
/* ── 生成弹窗 ── */
const [genModalOpen, setGenModalOpen] = useState(false);
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
const [genTask, setGenTask] = useState<TaskItem | null>(null);
const [genSubmitting, setGenSubmitting] = useState(false);
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
null,
);
/* ── 素材库 ── */
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
@@ -163,6 +246,19 @@ const EditingPlanner: React.FC = () => {
/* ── 播放 ── */
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [pixelsPerSecond, setPixelsPerSecond] = useState(40);
const prevFrameTimeRef = useRef<number | null>(null);
/** 播放头跳转 */
const handleSeek = useCallback((time: number) => {
setCurrentTime(Math.max(0, time));
}, []);
/** 轨道缩放 */
const handleZoomChange = useCallback((pps: number) => {
setPixelsPerSecond(pps);
}, []);
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
const voiceMaterialsQuery = useQuery({
@@ -256,16 +352,21 @@ const EditingPlanner: React.FC = () => {
size: tpl.title_config.font_size,
color: tpl.title_config.font_color || "#ffffff",
}));
setSubtitleSettings({
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
position: tpl.subtitle_config.position,
position: (tpl.subtitle_config.position ||
"bottom") as SubtitleStyleConfig["position"],
font: tpl.subtitle_config.font,
size: tpl.subtitle_config.size,
fontSize: tpl.subtitle_config.size,
fontColor: tpl.subtitle_config.color || "#ffffff",
animation: tpl.subtitle_config.animation,
});
setBgmSettings({
music: tpl.bgm_config.music_id || "none",
});
}));
setBgmSettings((prev) => ({
...prev,
enabled: tpl.bgm_config.enabled,
music_id: tpl.bgm_config.music_id || "",
}));
setDraftName(tpl.name);
setDraftCategory(tpl.category);
setDraftTags(tpl.tags.join(", "));
@@ -279,6 +380,31 @@ const EditingPlanner: React.FC = () => {
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0);
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
/* rAF 帧推进 — 播放时平滑更新播放头位置 */
useEffect(() => {
if (!isPlaying) {
prevFrameTimeRef.current = null;
return;
}
let rafId: number;
const tick = (timestamp: number) => {
if (prevFrameTimeRef.current !== null) {
const delta = (timestamp - prevFrameTimeRef.current) / 1000;
setCurrentTime((prev) => {
const next = prev + delta;
return next >= totalDuration ? totalDuration : next;
});
}
prevFrameTimeRef.current = timestamp;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafId);
prevFrameTimeRef.current = null;
};
}, [isPlaying, totalDuration]);
const filteredTemplates = templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false;
if (
@@ -349,6 +475,175 @@ const EditingPlanner: React.FC = () => {
[clips.length, setClips],
);
/* ── 裁剪更新:调整片段的 trim_config 和 duration ── */
const handleClipTrim = useCallback(
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? { ...c, trim_config: trimConfig, duration: newDuration }
: c,
),
);
},
[setClips],
);
/* ── 片段分割:在指定比例位置将片段一分为二 ── */
const handleClipSplit = useCallback(
(clipId: string, splitRatio: number) => {
setClips((prev) => {
const idx = prev.findIndex((c) => c.id === clipId);
if (idx === -1) return prev;
const clip = prev[idx];
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10;
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev;
// 前半段
const firstHalf: ClipData = {
...clip,
duration: splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
end_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
};
// 后半段
const secondHalf: ClipData = {
...clip,
id: `clip-${Date.now()}`,
duration: clip.duration - splitPoint,
startOffset: clip.startOffset + splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
start_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
order: (clip.order ?? idx) + 1,
};
const updated = [...prev];
updated[idx] = firstHalf;
updated.splice(idx + 1, 0, secondHalf);
return updated.map((c, i) => ({ ...c, order: i }));
});
},
[setClips],
);
/* ── 恢复片段原始长度 ── */
const handleClipResetTrim = useCallback(
(clipId: string) => {
setClips((prev) =>
prev.map((c) => {
if (c.id !== clipId || !c.trim_config) return c;
const originalDuration =
c.trim_config.original_duration ?? c.duration;
return {
...c,
duration: originalDuration,
trim_config: undefined,
};
}),
);
},
[setClips],
);
/* ── 转场特效变更 ── */
const handleTransitionChange = useCallback(
(config: TransitionConfig) => {
if (transitionTargetClipId) {
// 更新指定片段的转场
handleClipUpdate(transitionTargetClipId, { transition: config });
}
// 同时更新全局默认转场(供新片段使用)
},
[transitionTargetClipId],
);
/* ── 打开转场选择器 ── */
const handleOpenTransitionDrawer = useCallback((clipId?: string) => {
setTransitionTargetClipId(clipId ?? null);
setTransitionDrawerOpen(true);
}, []);
/* ── 调速变更 ── */
const handleSpeedChange = useCallback(
(config: SpeedConfig) => {
if (speedTargetClipId) {
handleClipUpdate(speedTargetClipId, { speed: config });
}
},
[speedTargetClipId],
);
/* ── 打开调速面板 ── */
const handleOpenSpeedDrawer = useCallback((clipId: string) => {
setSpeedTargetClipId(clipId);
setSpeedDrawerOpen(true);
}, []);
/* ── TTS 配音变更 ── */
const handleTtsChange = useCallback(
(ttsConfig: TtsConfig) => {
if (!ttsTargetClipId) return;
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
},
[ttsTargetClipId],
);
/* ── 打开 TTS 配音面板 ── */
const handleOpenTtsDrawer = useCallback((clipId: string) => {
setTtsTargetClipId(clipId);
setTtsDrawerOpen(true);
}, []);
/* ── 调速应用到所有片段 ── */
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
message.success("已应用到所有片段");
}, []);
/* ── 水印配置变更 ── */
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
setWatermarkSettings(config);
}, []);
/* ── 片头片尾配置变更 ── */
const handleIntroOutroChange = useCallback((config: IntroOutroConfig) => {
setIntroOutroSettings(config);
}, []);
/* ── 画中画配置变更 ── */
const handlePipChange = useCallback((config: PipConfig) => {
setPipSettings(config);
}, []);
/* ── 滤镜调色配置变更 ── */
const handleFilterChange = useCallback((config: FilterConfig) => {
setFilterSettings(config);
}, []);
/* ── 绿幕抠像配置变更 ── */
const handleChromaKeyChange = useCallback((config: ChromaKeyConfig) => {
setChromaKeySettings(config);
}, []);
/* ── 贴纸配置变更 ── */
const handleStickerChange = useCallback((config: StickerConfig) => {
setStickerSettings(config);
}, []);
/* ── 封面配置变更 ── */
const handleCoverChange = useCallback((config: CoverConfig) => {
setCoverSettings(config);
}, []);
/* AI 封面生成 */
const handleAiGenerateCover = async (
coverType: "ai_frame" | "ai_regenerate",
@@ -408,13 +703,13 @@ const EditingPlanner: React.FC = () => {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: "#ffffff",
size: subtitleSettings.size,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.music !== "none",
music_id: bgmSettings.music,
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
@@ -422,7 +717,35 @@ const EditingPlanner: React.FC = () => {
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverSettings },
};
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload);
@@ -462,12 +785,13 @@ const EditingPlanner: React.FC = () => {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
size: subtitleSettings.size,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.music !== "none",
music_id: bgmSettings.music,
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
mode: currentMode,
total_duration: totalDuration,
@@ -479,7 +803,35 @@ const EditingPlanner: React.FC = () => {
script_text: c.script_text,
voice_asset_id: c.voice_asset_id,
voice_file_url: c.voice_file_url,
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverSettings },
};
const params = new URLSearchParams();
if (loadedTemplateId) {
@@ -489,87 +841,6 @@ const EditingPlanner: React.FC = () => {
navigate(`/app/generate?${params.toString()}`);
};
/**
*
* 1. generateFromTemplate
* 2. createGenerationTask
* getTask TaskItem 使
*/
const handleGenerate = async () => {
if (!loadedTemplateId) return;
setGenSubmitting(true);
try {
await generateFromTemplate(loadedTemplateId, {
voiceover_duration: voiceoverDuration || totalDuration,
});
// 收集所有 voice 类型片段的配音素材 ID
const voiceIds = clips
.filter((c) => c.type === "voice" && c.voice_asset_id)
.map((c) => c.voice_asset_id as string);
const res = await createGenerationTask({
template_id: loadedTemplateId,
asset_ids: [],
title_ids: [],
voice_ids: voiceIds,
});
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
const task = await getTask(res.id);
setGenTask(task);
setGenPhase("progress");
message.info("生成任务已创建");
} catch {
message.error("创建生成任务失败");
} finally {
setGenSubmitting(false);
}
};
/**
* 3
* genPhase === "progress" ID
* /
*/
useEffect(() => {
if (genPhase !== "progress" || !genTask?.id) return;
const timer = setInterval(async () => {
try {
const t = await getTask(genTask.id);
setGenTask(t);
if (t.status === "completed") {
setGenPhase("completed");
clearInterval(timer);
} else if (t.status === "failed") {
setGenPhase("failed");
clearInterval(timer);
}
} catch {
/* ignore */
}
}, 3000);
return () => clearInterval(timer);
}, [genPhase, genTask?.id]);
const handleRetry = async () => {
if (!genTask?.id) return;
setGenSubmitting(true);
try {
const t = await retryTask(genTask.id);
setGenTask(t);
setGenPhase("progress");
} catch {
message.error("重试失败");
} finally {
setGenSubmitting(false);
}
};
const handleCancelGen = () => {
setGenModalOpen(false);
setGenPhase("setup");
setGenTask(null);
setVoiceoverDuration(null);
};
/* 查看生成历史 */
const handleViewGenHistory = async () => {
if (!loadedTemplateId) {
@@ -678,7 +949,13 @@ const EditingPlanner: React.FC = () => {
coverSchemes={COVER_SCHEMES}
aiCoverLoading={aiCoverLoading}
titleSettings={titleSettings}
subtitleSettings={subtitleSettings}
subtitleSettings={{
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
}}
onClipSelect={handleClipSelect}
onCoverSchemeChange={setCurrentCoverScheme}
onPlayPause={() => setIsPlaying(!isPlaying)}
@@ -694,6 +971,14 @@ const EditingPlanner: React.FC = () => {
onClipReorder={handleClipReorder}
onClipRemove={handleClipRemove}
onAddClip={handleAddClip}
onClipTrim={handleClipTrim}
onClipSplit={handleClipSplit}
onClipResetTrim={handleClipResetTrim}
currentTime={currentTime}
pixelsPerSecond={pixelsPerSecond}
onZoomChange={handleZoomChange}
onSeek={handleSeek}
totalDuration={totalDuration}
/>
</div>
@@ -710,16 +995,30 @@ const EditingPlanner: React.FC = () => {
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
onSubtitleSettingsChange={(partial) =>
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
setSubtitleSettings(
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
)
}
onBgmSettingsChange={(partial) =>
setBgmSettings((prev) => ({ ...prev, ...partial }))
}
onClipUpdate={handleClipUpdate}
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
onClipVoiceSelect={handleClipVoiceSelect}
onOpenTransitionDrawer={handleOpenTransitionDrawer}
onOpenSpeedDrawer={handleOpenSpeedDrawer}
onOpenTtsDrawer={handleOpenTtsDrawer}
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
onOpenPipDrawer={() => setPipDrawerOpen(true)}
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
/>
</div>
@@ -758,20 +1057,6 @@ const EditingPlanner: React.FC = () => {
onCancel={() => setSaveModalOpen(false)}
/>
<GenerationProgressModal
open={genModalOpen}
phase={genPhase}
voiceoverDuration={voiceoverDuration}
estimatedDuration={totalDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={handleGenerate}
task={genTask}
submitting={genSubmitting}
onCancel={handleCancelGen}
onRetry={handleRetry}
onClose={handleCancelGen}
/>
{/* ═══ 生成历史弹窗 ═══ */}
<GenerationHistoryModal
open={genHistoryOpen}
@@ -779,6 +1064,122 @@ const EditingPlanner: React.FC = () => {
history={genHistory}
onClose={() => setGenHistoryOpen(false)}
/>
{/* ═══ BGM 选择器 Drawer ═══ */}
<BgmSelector
open={bgmDrawerOpen}
onClose={() => setBgmDrawerOpen(false)}
config={bgmSettings}
onChange={setBgmSettings}
/>
{/* ═══ 字幕样式配置 Drawer ═══ */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={() => setSubtitleDrawerOpen(false)}
config={subtitleSettings}
onChange={setSubtitleSettings}
/>
{/* ═══ 转场特效选择器 Drawer ═══ */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={() => setTransitionDrawerOpen(false)}
config={
transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ??
DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
}
onChange={handleTransitionChange}
title={transitionTargetClipId ? "片段转场设置" : "全局默认转场"}
/>
{/* ═══ 片段调速面板 Drawer ═══ */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={() => setSpeedDrawerOpen(false)}
config={
clips.find((c) => c.id === speedTargetClipId)?.speed ??
DEFAULT_SPEED
}
onChange={handleSpeedChange}
onApplyAll={handleApplySpeedAll}
/>
)}
{/* ═══ TTS 配音面板 Drawer ═══ */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={() => setTtsDrawerOpen(false)}
config={
clips.find((c) => c.id === ttsTargetClipId)?.tts_config ??
DEFAULT_TTS_CONFIG
}
onChange={handleTtsChange}
/>
)}
{/* ═══ 水印配置面板 ═══ */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={() => setWatermarkDrawerOpen(false)}
config={watermarkSettings}
onChange={handleWatermarkChange}
/>
{/* ═══ 片头片尾配置面板 ═══ */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={() => setIntroOutroDrawerOpen(false)}
config={introOutroSettings}
onChange={handleIntroOutroChange}
/>
{/* ═══ 画中画配置面板 ═══ */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={() => setPipDrawerOpen(false)}
config={pipSettings}
onChange={handlePipChange}
totalDuration={totalDuration}
/>
{/* ═══ 滤镜调色面板 ═══ */}
<FilterPanel
open={filterDrawerOpen}
onClose={() => setFilterDrawerOpen(false)}
config={filterSettings}
onChange={handleFilterChange}
/>
{/* ═══ 绿幕抠像面板 ═══ */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={() => setChromaKeyDrawerOpen(false)}
config={chromaKeySettings}
onChange={handleChromaKeyChange}
/>
{/* ═══ 贴纸面板 ═══ */}
<StickerPanel
open={stickerDrawerOpen}
onClose={() => setStickerDrawerOpen(false)}
config={stickerSettings}
onChange={handleStickerChange}
totalDuration={totalDuration}
/>
{/* ═══ 封面选择器 ═══ */}
<CoverSelector
open={coverDrawerOpen}
onClose={() => setCoverDrawerOpen(false)}
config={coverSettings}
onChange={handleCoverChange}
totalDuration={totalDuration}
/>
</div>
);
};
@@ -0,0 +1,292 @@
/**
* BGM Drawer
* BGM //
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { Drawer, Slider, Input, Tag, message } from "antd";
import {
getBgmPresets,
type BgmPreset,
type BgmCategory,
type BgmMixConfig,
DEFAULT_BGM_MIX_CONFIG,
} from "@/api/bgm";
const { Search } = Input;
/* ──────────── 分类标签 ──────────── */
const CATEGORY_LIST: {
key: BgmCategory | "all";
label: string;
icon: string;
}[] = [
{ key: "all", label: "全部", icon: "🎶" },
{ key: "轻快", label: "轻快", icon: "🎉" },
{ key: "治愈", label: "治愈", icon: "🌿" },
{ key: "科技", label: "科技", icon: "🔬" },
{ key: "电商", label: "电商", icon: "🛒" },
];
/* ──────────── Props ──────────── */
interface BgmSelectorProps {
open: boolean;
onClose: () => void;
config: BgmMixConfig;
onChange: (config: BgmMixConfig) => void;
}
const BgmSelector: React.FC<BgmSelectorProps> = ({
open,
onClose,
config,
onChange,
}) => {
const [presets, setPresets] = useState<BgmPreset[]>([]);
const [loading, setLoading] = useState(false);
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">(
"all",
);
const [keyword, setKeyword] = useState("");
const [previewingId, setPreviewingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
setLoading(true);
try {
const params: { category?: string; keyword?: string } = {};
if (activeCategory !== "all") params.category = activeCategory;
if (keyword.trim()) params.keyword = keyword.trim();
const data = await getBgmPresets(params);
setPresets(data);
} catch {
message.error("加载 BGM 列表失败");
} finally {
setLoading(false);
}
}, [activeCategory, keyword]);
useEffect(() => {
if (open) loadPresets();
}, [open, loadPresets]);
/* ── 试听 ── */
const handlePreview = useCallback(
(bgm: BgmPreset) => {
if (previewingId === bgm.id) {
audioRef.current?.pause();
setPreviewingId(null);
return;
}
audioRef.current?.pause();
const audio = new Audio(bgm.url);
audioRef.current = audio;
audio.play().catch(() => {});
audio.onended = () => setPreviewingId(null);
setPreviewingId(bgm.id);
},
[previewingId],
);
/* ── 选中 BGM ── */
const handleSelect = useCallback(
(bgm: BgmPreset) => {
onChange({
...config,
enabled: true,
music_id: bgm.id,
});
},
[config, onChange],
);
/* ── 关闭时停止播放 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause();
setPreviewingId(null);
onClose();
}, [onClose]);
/* ── 移除 BGM ── */
const handleClear = useCallback(() => {
audioRef.current?.pause();
setPreviewingId(null);
onChange({ ...DEFAULT_BGM_MIX_CONFIG });
}, [onChange]);
/* ── 当前选中的 BGM ── */
const selectedBgm = presets.find((p) => p.id === config.music_id);
return (
<Drawer
title="🎵 BGM 音乐选择"
placement="right"
width={420}
open={open}
onClose={handleClose}
className="bgm-selector-drawer"
>
{/* ── 搜索框 ── */}
<div className="bgm-search-row">
<Search
placeholder="搜索 BGM 名称..."
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadPresets()}
/>
</div>
{/* ── 分类标签 ── */}
<div className="bgm-category-bar">
{CATEGORY_LIST.map((cat) => (
<Tag
key={cat.key}
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.icon} {cat.label}
</Tag>
))}
</div>
{/* ── BGM 列表 ── */}
<div className="bgm-list">
{loading && <div className="bgm-loading">...</div>}
{!loading && presets.length === 0 && (
<div className="bgm-empty"> BGM </div>
)}
{presets.map((bgm) => {
const isSelected = config.music_id === bgm.id;
const isPlaying = previewingId === bgm.id;
return (
<div
key={bgm.id}
className={`bgm-item${isSelected ? " selected" : ""}`}
onClick={() => handleSelect(bgm)}
>
<div className="bgm-item-cover">
{bgm.cover_url ? (
<img src={bgm.cover_url} alt={bgm.name} />
) : (
<span className="bgm-item-cover-icon">🎵</span>
)}
</div>
<div className="bgm-item-info">
<div className="bgm-item-name">{bgm.name}</div>
<div className="bgm-item-meta">
<span className="bgm-item-category">{bgm.category}</span>
<span className="bgm-item-duration">
{Math.floor(bgm.duration / 60)}:
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
</span>
</div>
{bgm.tags.length > 0 && (
<div className="bgm-item-tags">
{bgm.tags.slice(0, 3).map((t) => (
<span key={t} className="bgm-item-tag">
{t}
</span>
))}
</div>
)}
</div>
<button
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
onClick={(e) => {
e.stopPropagation();
handlePreview(bgm);
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶️"}
</button>
{isSelected && <span className="bgm-item-check"></span>}
</div>
);
})}
</div>
{/* ── 混音配置 ── */}
{config.enabled && config.music_id && (
<div className="bgm-mix-config">
<div className="bgm-mix-header">
<span></span>
<button className="bgm-mix-clear" onClick={handleClear}>
BGM
</button>
</div>
<div className="bgm-mix-selected">
{selectedBgm
? `当前:${selectedBgm.name}`
: `当前:${config.music_id}`}
</div>
{/* 音量 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.volume}%</span>
</label>
<Slider
min={0}
max={100}
value={config.volume}
onChange={(v) => onChange({ ...config, volume: v })}
/>
</div>
{/* 淡入 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
{" "}
<span className="bgm-mix-value">
{config.fade_in.toFixed(1)}s
</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_in}
onChange={(v) => onChange({ ...config, fade_in: v })}
/>
</div>
{/* 淡出 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
{" "}
<span className="bgm-mix-value">
{config.fade_out.toFixed(1)}s
</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_out}
onChange={(v) => onChange({ ...config, fade_out: v })}
/>
</div>
{/* 人声闪避 */}
<div className="bgm-mix-field bgm-mix-toggle-row">
<label className="bgm-mix-label">sidechain</label>
<div
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
onClick={() =>
onChange({ ...config, voice_dodge: !config.voice_dodge })
}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
)}
</Drawer>
);
};
export default BgmSelector;
@@ -5,32 +5,30 @@
import React, { useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import type { TemplateMode } from "@/api/editingPlanner";
import type { ClipData, ClipType } from "../types";
import type { ClipData, ClipType, TitleSettings } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
import type { AssetItem } from "@/api/assets";
interface TitleSettings {
aiAutoSelect: boolean;
title: string;
position: string;
font: string;
size: number;
bold: boolean;
italic: boolean;
stroke: boolean;
shadow: boolean;
color: string;
}
interface SubtitleSettings {
enabled: boolean;
position: string;
font: string;
size: number;
fontSize: number;
fontColor: string;
animation: string;
mode?: string;
stroke?: boolean;
shadow?: boolean;
asrLanguage?: string;
}
interface BgmSettings {
music: string;
enabled: boolean;
music_id: string;
volume?: number;
fade_in?: number;
fade_out?: number;
voice_dodge?: boolean;
}
interface ClipPropertiesPanelProps {
@@ -45,6 +43,10 @@ interface ClipPropertiesPanelProps {
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[];
/** 配音素材加载中 */
@@ -53,6 +55,26 @@ interface ClipPropertiesPanelProps {
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 */
onOpenCoverDrawer?: () => void;
}
const POSITION_OPTIONS = [
@@ -78,14 +100,6 @@ const ANIMATION_OPTIONS = [
{ value: "typewriter", label: "打字机" },
];
const BGM_OPTIONS = [
{ value: "none", label: "无背景音乐" },
{ value: "bgm_01", label: "🎵 轻快节奏" },
{ value: "bgm_02", label: "🎵 温馨舒缓" },
{ value: "bgm_03", label: "🎵 动感活力" },
{ value: "bgm_04", label: "🎵 科技感" },
];
/**
* ++++
*
@@ -272,12 +286,24 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
currentMode,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange,
onBgmSettingsChange: _onBgmSettingsChange,
onClipUpdate,
onOpenBgmDrawer,
onOpenSubtitleDrawer,
voiceMaterials = [],
voiceMaterialsLoading = false,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
onOpenWatermarkDrawer,
onOpenIntroOutroDrawer,
onOpenPipDrawer,
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
onOpenCoverDrawer,
}) => {
const navigate = useNavigate();
@@ -534,15 +560,17 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
<input
className="ep-slider"
type="range"
min={10}
max={32}
value={subtitleSettings.size}
min={12}
max={48}
value={subtitleSettings.fontSize}
onChange={(e) =>
onSubtitleSettingsChange({ size: Number(e.target.value) })
onSubtitleSettingsChange({
fontSize: Number(e.target.value),
})
}
/>
<span className="ep-slider-value">
{subtitleSettings.size}px
{subtitleSettings.fontSize}px
</span>
</div>
</div>
@@ -563,6 +591,16 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
))}
</select>
</div>
{/* 高级配置按钮 */}
{onOpenSubtitleDrawer && (
<button
className="ep-advanced-btn"
onClick={onOpenSubtitleDrawer}
>
🎨
</button>
)}
</>
)}
</div>
@@ -574,20 +612,130 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
BGM
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={bgmSettings.music}
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
>
{BGM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{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>
{/* ═══ 水印设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🔖</span>
</div>
{onOpenWatermarkDrawer && (
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
<span className="ep-advanced-btn-icon">🔖</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片头片尾设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎬</span>
</div>
{onOpenIntroOutroDrawer && (
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 画中画 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenPipDrawer && (
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 滤镜调色 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎨</span>
</div>
{onOpenFilterDrawer && (
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
<span className="ep-advanced-btn-icon">🎨</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 绿幕抠像 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🟩</span>
绿
</div>
{onOpenGreenScreenDrawer && (
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
<span className="ep-advanced-btn-icon">🟩</span>
<span className="ep-advanced-btn-label">绿</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 贴纸 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🏷</span>
</div>
{onOpenStickerDrawer && (
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
<span className="ep-advanced-btn-icon">🏷</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 封面 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenCoverDrawer && (
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片段详情(选中时显示) ═══ */}
@@ -649,6 +797,71 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</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">
@@ -0,0 +1,301 @@
/**
*
* + +
*/
import React, { useCallback, useRef, useState } from "react";
import { Drawer } from "antd";
import type { CoverConfig, CoverMode } from "../types";
import { DEFAULT_COVER_CONFIG } from "../types";
interface CoverSelectorProps {
open: boolean;
onClose: () => void;
config: CoverConfig;
onChange: (config: CoverConfig) => void;
totalDuration: number;
}
/** 封面模式标签 */
const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
};
/** 封面模式图标 */
const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
};
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 切换模式 */
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode });
},
[update],
);
/** 处理文件上传 */
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = (e) => {
const url = e.target?.result as string;
update({ upload_url: url, thumbnail_url: url, mode: "upload" });
};
reader.readAsDataURL(file);
},
[update],
);
/** 拖拽上传 */
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFileUpload(file);
},
[handleFileUpload],
);
/** 使用 AI 推荐时间 */
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" });
}
}, [config.ai_suggested_time, update]);
/** 格式化时间 */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 10);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`;
};
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{/* 智能封面 */}
{config.mode === "auto" && (
<div className="cover-auto-section">
<div className="cover-auto-desc">
AI
</div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">
{formatTime(config.ai_suggested_time)}
</div>
<button
className="cover-auto-use-btn"
onClick={handleUseAiSuggestion}
>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)}
{/* 抽帧选封面 */}
{config.mode === "frame" && (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">
{formatTime(config.frame_time)}
</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">
{formatTime(config.frame_time)}
</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => update({ frame_time: Number(e.target.value) })}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
{/* 快捷时间点 */}
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio;
return (
<button
key={ratio}
className="cover-quick-btn"
onClick={() => update({ frame_time: t })}
>
{formatTime(t)}
</button>
);
})}
</div>
</div>
)}
{/* 上传封面 */}
{config.mode === "upload" && (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text">
</span>
<span className="cover-upload-hint">
JPG / PNG 16:9
</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
}}
/>
</div>
</div>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img
src={config.upload_url}
alt="封面预览"
className="cover-preview-img"
/>
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default CoverSelector;
@@ -0,0 +1,234 @@
/**
*
* + /////
*/
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";
interface FilterPanelProps {
open: boolean;
onClose: () => void;
config: FilterConfig;
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>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 选择预设时重置手动参数 */
const handlePresetSelect = useCallback(
(preset: FilterPreset) => {
if (preset === "none") {
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
} else {
onChange({
...DEFAULT_FILTER_CONFIG,
enabled: config.enabled,
preset,
});
}
},
[config.enabled, onChange],
);
return (
<Drawer
title="滤镜调色"
placement="right"
width={420}
open={open}
onClose={onClose}
className="filter-panel-drawer"
>
{/* 顶部开关 */}
<div className="filter-header">
<span className="filter-header-label"></span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => update({ enabled: checked })}
/>
</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>
{/* 手动调节 */}
<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>
{/* 预览色块 */}
<div className="filter-section">
<div className="filter-section-title"></div>
<div
className="filter-preview-block"
style={{
background: PRESET_GRADIENTS[config.preset],
filter: [
`brightness(${100 + config.brightness}%)`,
`contrast(${100 + config.contrast}%)`,
`saturate(${100 + config.saturation}%)`,
].join(" "),
}}
/>
</div>
{/* 底部重置 */}
<div className="filter-footer">
<button className="filter-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default FilterPanel;
@@ -0,0 +1,219 @@
/**
* 绿
* 5 + + //
*/
import React, { useCallback } from "react";
import { Drawer, Switch } from "antd";
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types";
import {
DEFAULT_CHROMA_KEY_CONFIG,
CHROMA_KEY_PRESET_LABELS,
CHROMA_KEY_PRESET_COLORS,
} from "../types";
interface GreenScreenPanelProps {
open: boolean;
onClose: () => void;
config: ChromaKeyConfig;
onChange: (config: ChromaKeyConfig) => void;
}
/** 预设列表 */
const PRESET_LIST: ChromaKeyColorPreset[] = [
"green",
"blue",
"red",
"pure_green",
"soft_green",
];
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
const update = useCallback(
(partial: Partial<ChromaKeyConfig>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 选择颜色预设时同步更新 color 字段 */
const handlePresetSelect = useCallback(
(preset: ChromaKeyColorPreset) => {
update({
color_preset: preset,
color: CHROMA_KEY_PRESET_COLORS[preset],
});
},
[update],
);
/** 自定义颜色变化时清除预设标记 */
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
update({ color: e.target.value });
},
[update],
);
return (
<Drawer
title="绿幕抠像"
placement="right"
width={420}
open={open}
onClose={onClose}
className="green-screen-panel-drawer"
>
{/* 顶部开关 */}
<div className="green-header">
<span className="green-header-label">绿</span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => update({ enabled: checked })}
/>
</div>
{/* 颜色预设 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<span
className="green-preset-dot"
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
/>
<span className="green-preset-label">
{CHROMA_KEY_PRESET_LABELS[p]}
</span>
</button>
))}
</div>
</div>
{/* 自定义颜色 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-color-row">
<input
type="color"
className="green-color-picker"
value={config.color}
onChange={handleColorChange}
/>
<input
type="text"
className="green-color-hex"
value={config.color}
onChange={handleColorChange}
placeholder="#00FF00"
/>
<div
className="green-color-swatch"
style={{ background: config.color }}
/>
</div>
</div>
{/* 参数调节 */}
<div className="green-section">
<div className="green-section-title"></div>
{/* 相似度 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.similarity}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.similarity}
onChange={(e) => update({ similarity: Number(e.target.value) })}
/>
<div className="green-slider-desc">广</div>
</div>
{/* 边缘平滑 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.blend}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.blend}
onChange={(e) => update({ blend: Number(e.target.value) })}
/>
<div className="green-slider-desc"></div>
</div>
{/* 溢色抑制 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.spill}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.spill}
onChange={(e) => update({ spill: Number(e.target.value) })}
/>
<div className="green-slider-desc"></div>
</div>
</div>
{/* 预览 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-preview-box">
<div
className="green-preview-bg"
style={{ background: config.color, opacity: 0.3 }}
/>
<div className="green-preview-subject">
<div className="green-preview-circle" />
<div className="green-preview-text"></div>
</div>
<div
className="green-preview-edge"
style={{
borderColor: config.color,
filter: `blur(${config.blend / 10}px)`,
}}
/>
</div>
</div>
{/* 底部重置 */}
<div className="green-footer">
<button className="green-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default GreenScreenPanel;
@@ -0,0 +1,309 @@
/**
* Drawer
* Intro/ Outro
* // URL
*/
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/editPlans";
/* ──────────── 常量 ──────────── */
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;
config: IntroOutroConfig;
onChange: (config: IntroOutroConfig) => void;
}
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
/* ── 更新片头 ── */
const handleIntroChange = useCallback(
(partial: Partial<IntroOutroItem>) => {
onChange({ ...config, intro: { ...config.intro, ...partial } });
},
[config, onChange],
);
/* ── 更新片尾 ── */
const handleOutroChange = useCallback(
(partial: Partial<IntroOutroItem>) => {
onChange({ ...config, outro: { ...config.outro, ...partial } });
},
[config, onChange],
);
/* ── 切换片头类型 ── */
const handleIntroKindChange = useCallback(
(kind: IntroOutroKind) => {
handleIntroChange({ kind, url: kind === "none" ? undefined : "" });
},
[handleIntroChange],
);
/* ── 切换片尾类型 ── */
const handleOutroKindChange = useCallback(
(kind: IntroOutroKind) => {
handleOutroChange({ kind, url: kind === "none" ? undefined : "" });
},
[handleOutroChange],
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_INTRO_OUTRO });
}, [onChange]);
return (
<Drawer
title="🎬 片头片尾设置"
placement="right"
width={420}
open={open}
onClose={onClose}
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>
{/* ═══ 片尾区块 ═══ */}
<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>
{/* ── 底部操作 ── */}
<div className="iop-footer">
<button className="iop-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default IntroOutroPanel;
@@ -0,0 +1,582 @@
/**
* Drawer
* + +
*/
import React, { useCallback, useMemo } 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 ──────────── */
interface PipConfigPanelProps {
open: boolean;
onClose: () => void;
config: PipConfig;
onChange: (config: PipConfig) => void;
totalDuration: number;
}
/* ──────────── 辅助函数 ──────────── */
let layerIdCounter = 0;
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`;
/* ──────────── 组件 ──────────── */
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
open,
onClose,
config,
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],
);
return (
<Drawer
title="🖼️ 画中画设置"
placement="right"
width={520}
open={open}
onClose={onClose}
className="pip-config-panel-drawer"
>
{/* ═══ 顶部工具栏 ═══ */}
<div className="pip-toolbar">
<div className="pip-toolbar-left">
<button className="pip-add-btn" onClick={handleAddLayer}>
+
</button>
</div>
<div className="pip-enable-switch">
<span></span>
<Switch
size="small"
checked={config.enabled}
onChange={handleEnableToggle}
/>
</div>
</div>
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
<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>
</div>
{/* ── 底部操作 ── */}
<div className="pip-footer">
<button className="pip-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default PipConfigPanel;
@@ -4,25 +4,13 @@
* 4
*/
import React from "react";
import type { ClipData, ClipType } from "../types";
import type { ClipData, ClipType, TitleSettings } from "../types";
interface CoverScheme {
key: string;
label: string;
}
interface TitleSettings {
aiAutoSelect: boolean;
title: string;
position: string;
font: string;
size: number;
bold: boolean;
italic: boolean;
stroke: boolean;
shadow: boolean;
}
interface SubtitleSettings {
enabled: boolean;
position: string;
@@ -0,0 +1,157 @@
/**
* Drawer
* 0.25x ~ 4x+ +
* /
*/
import React, { useCallback } from "react";
import { Drawer, Slider } from "antd";
import type { SpeedConfig } from "../types";
import { DEFAULT_SPEED } from "../types";
/* ──────────── 预设速度 ──────────── */
const SPEED_PRESETS: { rate: number; label: string }[] = [
{ rate: 0.5, label: "0.5x" },
{ rate: 1.0, label: "1x" },
{ rate: 1.5, label: "1.5x" },
{ rate: 2.0, label: "2x" },
];
/* ──────────── Props ──────────── */
interface SpeedPanelProps {
open: boolean;
onClose: () => void;
/** 当前片段调速配置 */
config: SpeedConfig;
onChange: (config: SpeedConfig) => void;
/** 应用到所有片段 */
onApplyAll?: (config: SpeedConfig) => void;
}
const SpeedPanel: React.FC<SpeedPanelProps> = ({
open,
onClose,
config,
onChange,
onApplyAll,
}) => {
/* ── 修改速度 ── */
const handleChangeRate = useCallback(
(rate: number) => {
onChange({ ...config, rate });
},
[config, onChange],
);
/* ── 切换音调修正 ── */
const handleTogglePitch = useCallback(() => {
onChange({ ...config, pitchCorrection: !config.pitchCorrection });
}, [config, onChange]);
/* ── 选择预设 ── */
const handlePreset = useCallback(
(rate: number) => {
onChange({ ...config, rate });
},
[config, onChange],
);
/* ── 应用到所有片段 ── */
const handleApplyAll = useCallback(() => {
onApplyAll?.(config);
}, [config, onApplyAll]);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_SPEED });
}, [onChange]);
/* ── 速度描述文字 ── */
const speedLabel =
config.rate < 1
? "慢速(慢动作)"
: config.rate === 1
? "原速"
: config.rate < 2
? "快速"
: "极速";
return (
<Drawer
title="⚡ 片段调速"
placement="right"
width={380}
open={open}
onClose={onClose}
className="speed-panel-drawer"
>
{/* ── 速度滑块 ── */}
<div className="sp-speed-section">
<div className="sp-speed-header">
<span className="sp-speed-label"></span>
<span className="sp-speed-value">{config.rate.toFixed(2)}x</span>
</div>
<Slider
min={0.25}
max={4.0}
step={0.05}
value={config.rate}
onChange={handleChangeRate}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="sp-speed-marks">
<span>0.25x</span>
<span>1x</span>
<span>2x</span>
<span>4x</span>
</div>
<div className="sp-speed-desc">{speedLabel}</div>
</div>
{/* ── 预设快捷按钮 ── */}
<div className="sp-presets">
<div className="sp-presets-label"></div>
<div className="sp-presets-row">
{SPEED_PRESETS.map((p) => (
<button
key={p.rate}
className={`sp-preset-btn${Math.abs(config.rate - p.rate) < 0.01 ? " active" : ""}`}
onClick={() => handlePreset(p.rate)}
>
{p.label}
</button>
))}
</div>
</div>
{/* ── 音调修正开关 ── */}
<div className="sp-pitch-section">
<div className="sp-pitch-info">
<span className="sp-pitch-label"></span>
<span className="sp-pitch-desc">
{config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"}
</span>
</div>
<div
className={`ep-toggle${config.pitchCorrection ? " active" : ""}`}
onClick={handleTogglePitch}
>
<div className="ep-toggle-knob" />
</div>
</div>
{/* ── 底部操作 ── */}
<div className="sp-footer">
<button className="sp-reset-btn" onClick={handleReset}>
</button>
{onApplyAll && (
<button className="sp-apply-all-btn" onClick={handleApplyAll}>
</button>
)}
</div>
</Drawer>
);
};
export default SpeedPanel;
@@ -0,0 +1,561 @@
/**
*
* emoji / + +
*/
import React, { useCallback, useState } 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";
interface StickerPanelProps {
open: boolean;
onClose: () => void;
config: StickerConfig;
onChange: (config: StickerConfig) => void;
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,
config,
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("");
return (
<Drawer
title="贴纸"
placement="right"
width={460}
open={open}
onClose={onClose}
className="sticker-panel-drawer"
>
{/* 顶部开关 */}
<div className="sticker-header">
<span className="sticker-header-label"></span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => onChange({ ...config, enabled: checked })}
/>
</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>
{/* 已添加贴纸列表 */}
{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>
)}
{/* 选中贴纸的属性编辑 */}
{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>
)}
{/* 底部重置 */}
<div className="sticker-footer">
<button className="sticker-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default StickerPanel;
@@ -0,0 +1,274 @@
/**
* Drawer
* / ASR /ASR
*/
import React from "react";
import { Drawer, Slider, ColorPicker, Select } from "antd";
import type { Color } from "antd/es/color-picker";
/* ──────────── 类型 ──────────── */
export type SubtitleMode = "manual" | "asr";
export interface SubtitleStyleConfig {
/** 是否启用字幕 */
enabled: boolean;
/** 字幕模式:手动输入 / ASR 自动识别 */
mode: SubtitleMode;
/** 字体大小 px */
fontSize: number;
/** 字体颜色 */
fontColor: string;
/** 描边 */
stroke: boolean;
/** 阴影 */
shadow: boolean;
/** 字幕位置 */
position: "top" | "center" | "bottom";
/** 字体 */
font: string;
/** 动画效果 */
animation: string;
/** ASR 语言(仅 ASR 模式) */
asrLanguage: "zh" | "en";
}
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
enabled: true,
mode: "asr",
fontSize: 16,
fontColor: "#ffffff",
stroke: true,
shadow: false,
position: "bottom",
font: "思源黑体",
animation: "none",
asrLanguage: "zh",
};
/* ──────────── 选项常量 ──────────── */
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 ──────────── */
interface SubtitleStylePanelProps {
open: boolean;
onClose: () => void;
config: SubtitleStyleConfig;
onChange: (config: SubtitleStyleConfig) => void;
}
const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
const update = (partial: Partial<SubtitleStyleConfig>) => {
onChange({ ...config, ...partial });
};
return (
<Drawer
title="💬 字幕样式配置"
placement="right"
width={380}
open={open}
onClose={onClose}
className="subtitle-style-drawer"
>
{/* ── 字幕开关 ── */}
<div className="sub-field">
<div className="sub-toggle-row">
<span className="sub-label"></span>
<div
className={`ep-toggle${config.enabled ? " active" : ""}`}
onClick={() => update({ enabled: !config.enabled })}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
{config.enabled && (
<>
{/* ── 模式切换 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-mode-switch">
<button
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
onClick={() => update({ mode: "manual" })}
>
</button>
<button
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
onClick={() => update({ mode: "asr" })}
>
🤖 ASR
</button>
</div>
</div>
{/* ── ASR 语言(仅 ASR 模式) ── */}
{config.mode === "asr" && (
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.asrLanguage}
onChange={(v) => update({ asrLanguage: v })}
options={ASR_LANGUAGE_OPTIONS}
popupMatchSelectWidth={false}
/>
</div>
)}
{/* ── 字体大小 ── */}
<div className="sub-field">
<label className="sub-label">
<span className="sub-value">{config.fontSize}px</span>
</label>
<Slider
min={12}
max={48}
value={config.fontSize}
onChange={(v) => update({ fontSize: v })}
/>
</div>
{/* ── 字体颜色 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-color-row">
<ColorPicker
value={config.fontColor}
onChange={(_color: Color, hex: string) =>
update({ fontColor: hex })
}
showText
/>
</div>
</div>
{/* ── 字体 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.font}
onChange={(v) => update({ font: v })}
options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))}
popupMatchSelectWidth={false}
/>
</div>
{/* ── 字幕位置 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-position-group">
{POSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
onClick={() =>
update({
position: opt.value as SubtitleStyleConfig["position"],
})
}
>
{opt.label}
</button>
))}
</div>
</div>
{/* ── 描边 / 阴影 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-effect-btns">
<button
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
onClick={() => update({ stroke: !config.stroke })}
>
S
</button>
<button
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
onClick={() => update({ shadow: !config.shadow })}
>
</button>
</div>
</div>
{/* ── 动画 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.animation}
onChange={(v) => update({ animation: v })}
options={ANIMATION_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
popupMatchSelectWidth={false}
/>
</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>
</>
)}
</Drawer>
);
};
export default SubtitleStylePanel;
@@ -1,7 +1,12 @@
/**
* 线 = +
* 线
* (20px) + (100x100) + HTML5拖拽排序
* "+" +
*
*
* - /
* - //
* - / /
*/
import React, {
useState,
@@ -11,7 +16,8 @@ import React, {
useLayoutEffect,
useMemo,
} from "react";
import type { ClipData, ClipType } from "../types";
import type { ClipData, ClipType, TrimConfig } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
interface TimelinePanelProps {
clips: ClipData[];
@@ -21,6 +27,26 @@ interface TimelinePanelProps {
onClipReorder: (fromIdx: number, toIdx: number) => void;
onClipRemove: (clipId: string) => void;
onAddClip: (type: ClipType, duration: number) => void;
/** 裁剪更新:调整片段的 trim_config 和 duration */
onClipTrim?: (
clipId: string,
trimConfig: TrimConfig,
newDuration: number,
) => void;
/** 在指定位置分割片段 */
onClipSplit?: (clipId: string, splitRatio: number) => void;
/** 恢复片段原始长度 */
onClipResetTrim?: (clipId: string) => void;
/** 当前播放时间(秒) */
currentTime?: number;
/** 缩放:每秒像素数 */
pixelsPerSecond?: number;
/** 缩放变更回调 */
onZoomChange?: (pps: number) => void;
/** 播放头跳转回调 */
onSeek?: (time: number) => void;
/** 总时长(秒),可选(默认由 clips 计算) */
totalDuration?: number;
}
/** 片段类型图标 */
@@ -35,6 +61,25 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
pip: "画中画",
};
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right";
/** 裁剪拖拽状态 */
interface TrimDragState {
clipId: string;
direction: TrimDirection;
startX: number;
originalTrim: TrimConfig;
originalDuration: number;
}
/** 右键菜单状态 */
interface ContextMenuState {
x: number;
y: number;
clipId: string;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
clips,
selectedClipId,
@@ -43,6 +88,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onClipReorder,
onClipRemove,
onAddClip,
onClipTrim,
onClipSplit,
onClipResetTrim,
currentTime = 0,
pixelsPerSecond = 40,
onZoomChange,
onSeek,
totalDuration: totalDurationProp,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
@@ -55,6 +108,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
right: 0,
});
/* ── 裁剪拖拽状态 ── */
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null);
const [trimPreview, setTrimPreview] = useState<{
clipId: string;
startTime: number;
endTime: number;
duration: number;
x: number;
y: number;
} | null>(null);
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null);
/* ── 播放头拖拽状态 ── */
const [playheadDragging, setPlayheadDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
@@ -66,7 +141,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
[currentMode],
);
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice";
if (currentMode === "pip") return "pip";
@@ -83,33 +158,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setAddType(defaultAddType);
}
}, [currentMode, addType, availableTypes, defaultAddType]);
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
/* ── 面板尺寸 ── */
const PICKER_W = 240;
const GAP = 6;
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return;
const rect = addCardRef.current.getBoundingClientRect();
const vw = window.innerWidth;
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
const roughHeight = 180;
let top = rect.top - GAP - roughHeight;
if (top < 8) top = 8;
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
let right = vw - rect.right;
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, []);
const handleTogglePicker = () => {
if (!showAddPicker) {
// 打开面板时,默认选中当前模式下的第一个可用类型
const defaultType =
currentMode === "pip"
? "pip"
@@ -122,35 +192,27 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setShowAddPicker((v) => !v);
};
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
const pickerEl = pickerRef.current;
const addRect = addCardRef.current.getBoundingClientRect();
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
const pickerH = pickerEl.offsetHeight;
const vh = window.innerHeight;
const vw = window.innerWidth;
/* 默认:面板在"+"按钮上方 */
let top = addRect.top - GAP - pickerH;
/* 上方空间也不够(极端情况)→ 翻转到下方 */
if (top < 8) {
top = addRect.bottom + GAP;
/* 下方也溢出 → clamp */
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH;
if (top < 8) top = 8;
}
}
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
let right = vw - addRect.right;
const pickerRect = pickerEl.getBoundingClientRect();
if (pickerRect.left < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, [showAddPicker]);
@@ -167,6 +229,70 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddPicker]);
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(e.target as Node)
) {
setContextMenu(null);
}
};
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [contextMenu]);
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? 40;
const totalDuration =
totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0);
/* ── 播放头拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!playheadDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const trackEl = trackRef.current;
if (!trackEl) return;
const rect = trackEl.getBoundingClientRect();
const x = e.clientX - rect.left + trackEl.scrollLeft;
const time = Math.max(0, Math.min(x / pps, totalDuration));
onSeek?.(Math.round(time * 10) / 10);
};
const handleMouseUp = () => {
setPlayheadDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [playheadDragging, pps, totalDuration, onSeek]);
/* ── 标尺点击跳转播放头 ── */
const handleRulerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const time = Math.max(0, Math.min(x / pps, totalDuration));
onSeek?.(Math.round(time * 10) / 10);
},
[pps, totalDuration, onSeek],
);
/* ── 播放头拖拽开始 ── */
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPlayheadDragging(true);
}, []);
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration);
@@ -175,6 +301,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
// 如果正在裁剪拖拽,不允许排序拖拽
if (trimDrag) return;
dragRef.current = idx;
setDragIdx(idx);
e.dataTransfer.setData("application/x-clip-drag", String(idx));
@@ -210,8 +338,132 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
e.preventDefault();
};
/* ── 裁剪手柄拖拽 ── */
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
e.preventDefault();
e.stopPropagation();
const clip = clips.find((c) => c.id === clipId);
if (!clip) return;
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
};
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
});
},
[clips],
);
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return;
const PX_PER_SECOND = pixelsPerSecond ?? 40; // 与缩放级别同步
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX;
const dtSec = dx / PX_PER_SECOND;
const clip = clips.find((c) => c.id === trimDrag.clipId);
if (!clip) return;
const origTrim = trimDrag.originalTrim;
const origDur = origTrim.original_duration ?? trimDrag.originalDuration;
let newStart = origTrim.start_time;
let newEnd = origTrim.end_time;
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(
0,
Math.min(origTrim.start_time + dtSec, newEnd - 1),
);
} else {
// 右手柄:调整出点
newEnd = Math.max(
origTrim.start_time + 1,
Math.min(origTrim.end_time + dtSec, origDur),
);
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10;
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
});
};
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration:
trimDrag.originalTrim.original_duration ??
trimDrag.originalDuration,
};
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration);
}
setTrimDrag(null);
setTrimPreview(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]);
/* ── 右键菜单 ── */
const handleContextMenu = useCallback(
(e: React.MouseEvent, clipId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, clipId });
},
[],
);
/* ── 右键菜单操作 ── */
const handleContextSplit = useCallback(() => {
if (!contextMenu) return;
if (onClipSplit) {
onClipSplit(contextMenu.clipId, 0.5); // 在中间分割
}
setContextMenu(null);
}, [contextMenu, onClipSplit]);
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return;
if (onClipResetTrim) {
onClipResetTrim(contextMenu.clipId);
}
setContextMenu(null);
}, [contextMenu, onClipResetTrim]);
const handleContextDelete = useCallback(() => {
if (!contextMenu) return;
onClipRemove(contextMenu.clipId);
setContextMenu(null);
}, [contextMenu, onClipRemove]);
/* ── 时间标尺 ── */
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
const trackWidth = Math.max(totalDuration * pps, 300);
const rulerMarks: number[] = [];
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
for (let t = 0; t <= totalDuration + step; t += step) {
@@ -224,6 +476,11 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return `${m}:${s.toString().padStart(2, "0")}`;
};
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = (sec: number) => {
return `${sec.toFixed(1)}s`;
};
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
@@ -235,6 +492,33 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</span>
</div>
<div className="ep-timeline-actions">
{/* 缩放控件 */}
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
title="缩小"
>
</button>
<input
type="range"
className="ep-zoom-slider"
min={10}
max={120}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
title="放大"
>
+
</button>
<span className="ep-zoom-label">{pps}px/s</span>
</div>
<button
className="ep-timeline-action-btn"
onClick={() => {
@@ -260,21 +544,13 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 时间标尺 */}
{currentMode !== "one_take" && (
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
<div className="ep-time-ruler" onClick={handleRulerClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
style={{ left: `${t * pps}px` }}
>
{t}s
</span>
@@ -285,52 +561,151 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 水平片段轨道 */}
{currentMode !== "one_take" && (
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
<div
className="ep-clip-track"
ref={trackRef}
onDragOver={handleEmptyDragOver}
>
{/* 播放头 */}
{currentMode !== "one_take" && totalDuration > 0 && (
<div
className="ep-playhead"
style={{ left: currentTime * pps }}
onMouseDown={handlePlayheadMouseDown}
>
<div className="ep-playhead-handle" />
</div>
)}
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text"> + </div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
clips.map((clip, idx) => {
/* 转场指示器 */
const trans = clip.transition;
const showTransition = idx > 0 && trans && trans.type !== "none";
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined;
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
/* 速度徽章 */
const speed = clip.speed;
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01;
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
))
/* 裁剪状态 */
const hasTrim = !!clip.trim_config;
const isHovered = hoveredClipId === clip.id;
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">
{trans!.duration.toFixed(1)}s
</span>
</div>
)}
<div
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, 60) }}
draggable={!trimDrag}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
onContextMenu={(e) => handleContextMenu(e, clip.id)}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
>
{/* 左裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) =>
handleTrimHandleMouseDown(e, clip.id, "left")
}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && (
<span className="ep-speed-badge">
{speed!.rate.toFixed(1)}x
</span>
)}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) =>
handleTrimHandleMouseDown(e, clip.id, "right")
}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
</React.Fragment>
);
})
)}
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
{/* ── 轨道末尾 "+" 添加卡片 ── */}
<div className="ep-track-add-card-wrapper">
<div
ref={addCardRef}
@@ -344,7 +719,73 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</div>
)}
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
{/* 裁剪预览 tooltip */}
{trimPreview && (
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: trimPreview.x + 12,
top: trimPreview.y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.startTime)}
</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.endTime)}
</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.duration)}
</span>
</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
ref={contextMenuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
}}
>
<div className="ep-context-menu-item" onClick={handleContextSplit}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
<div
className="ep-context-menu-item"
onClick={handleContextResetTrim}
>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
)}
<div className="ep-context-menu-divider" />
<div
className="ep-context-menu-item ep-context-menu-item-danger"
onClick={handleContextDelete}
>
<span className="ep-context-menu-icon">🗑</span>
<span></span>
</div>
</div>
)}
{/* 类型+时长选择面板 */}
{showAddPicker && (
<div
ref={pickerRef}
@@ -0,0 +1,118 @@
/**
* Drawer
* 14 +
* +
*/
import React, { useCallback } from "react";
import { Drawer, Slider } from "antd";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
import type { TransitionConfig, TransitionType } from "../types";
import { DEFAULT_TRANSITION } from "../types";
/* ──────────── Props ──────────── */
interface TransitionSelectorProps {
open: boolean;
onClose: () => void;
/** 当前转场配置 */
config: TransitionConfig;
onChange: (config: TransitionConfig) => void;
/** 标题提示(区分全局 / 片段间) */
title?: string;
}
const TransitionSelector: React.FC<TransitionSelectorProps> = ({
open,
onClose,
config,
onChange,
title = "转场特效",
}) => {
/* ── 选择转场类型 ── */
const handleSelectType = useCallback(
(type: TransitionType) => {
onChange({ ...config, type });
},
[config, onChange],
);
/* ── 修改时长 ── */
const handleChangeDuration = useCallback(
(duration: number) => {
onChange({ ...config, duration });
},
[config, onChange],
);
/* ── 重置为无转场 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TRANSITION });
}, [onChange]);
return (
<Drawer
title={`🎬 ${title}`}
placement="right"
width={480}
open={open}
onClose={onClose}
className="transition-selector-drawer"
>
{/* ── 时长滑块 ── */}
<div className="ts-duration-section">
<div className="ts-duration-header">
<span className="ts-duration-label"></span>
<span className="ts-duration-value">
{config.duration.toFixed(1)}s
</span>
</div>
<Slider
min={0.3}
max={2.0}
step={0.1}
value={config.duration}
onChange={handleChangeDuration}
tooltip={{ formatter: (v) => `${(v as number).toFixed(1)}s` }}
/>
<div className="ts-duration-marks">
<span>0.3s</span>
<span>1.0s</span>
<span>2.0s</span>
</div>
</div>
{/* ── 转场类型卡片网格 ── */}
<div className="ts-grid">
{TRANSITION_OPTIONS.map((opt) => {
const isActive = config.type === opt.value;
return (
<div
key={opt.value}
className={`ts-card${isActive ? " active" : ""}`}
onClick={() => handleSelectType(opt.value)}
>
<div className="ts-card-icon">{opt.icon}</div>
<div className="ts-card-name">{opt.label}</div>
{isActive && <span className="ts-card-check"></span>}
</div>
);
})}
</div>
{/* ── 底部操作 ── */}
<div className="ts-footer">
<button className="ts-reset-btn" onClick={handleReset}>
</button>
<div className="ts-current">
{TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ??
"无转场"}
{" · "}
{config.duration.toFixed(1)}s
</div>
</div>
</Drawer>
);
};
export default TransitionSelector;
@@ -0,0 +1,349 @@
/**
* 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";
/* ──────────── 音色卡片分类图标 ──────────── */
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;
/** 当前片段 TTS 配置 */
config: TtsConfig;
onChange: (config: TtsConfig) => void;
}
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);
return (
<Drawer
title="🎙️ TTS 配音"
placement="right"
width={400}
open={open}
onClose={handleClose}
className="tts-panel-drawer"
>
{/* ── 配音模式切换 ── */}
<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) => (
<button
key={m.mode}
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
onClick={() => handleModeChange(m.mode)}
>
<span className="tts-mode-icon">{m.icon}</span>
<span className="tts-mode-text">{m.label}</span>
</button>
))}
</div>
</div>
{/* ── TTS 配置(仅 tts 模式显示) ── */}
{config.mode === "tts" && (
<>
{/* 文本输入 */}
<div className="tts-text-section">
<div className="tts-text-header">
<span className="tts-text-label"></span>
<span className="tts-text-count">{config.text.length}/5000</span>
</div>
<textarea
className="tts-text-input"
placeholder="请输入需要合成的文本内容..."
value={config.text}
onChange={handleTextChange}
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>
{/* 语速滑块 */}
<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>
{/* 语调滑块 */}
<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>
{/* 音量滑块 */}
<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>
{/* 试听按钮 */}
<div className="tts-preview-section">
<button
className="tts-preview-btn"
onClick={handlePreview}
disabled={previewLoading}
>
{previewLoading ? "⏳ 生成中..." : "🔊 试听"}
</button>
</div>
{/* 字幕联动 */}
<div className="tts-subtitle-section">
<div className="tts-subtitle-info">
<span className="tts-subtitle-label"></span>
<span className="tts-subtitle-desc">
{config.subtitle_sync
? "TTS 文本自动同步到字幕"
: "字幕需手动编辑"}
</span>
</div>
<div
className={`ep-toggle${config.subtitle_sync ? " active" : ""}`}
onClick={handleSubtitleSyncToggle}
>
<div className="ep-toggle-knob" />
</div>
</div>
</>
)}
{/* ── 上传配音模式提示 ── */}
{config.mode === "upload" && (
<div className="tts-upload-hint">
<p></p>
<p></p>
</div>
)}
{/* ── 底部操作 ── */}
{config.mode === "tts" && (
<div className="tts-footer">
<button className="tts-reset-btn" onClick={handleReset}>
</button>
</div>
)}
</Drawer>
);
};
export default TtsPanel;
@@ -0,0 +1,419 @@
/**
* Drawer
* Tab / /
*
*/
import React, { useCallback, useState } from "react";
import { Drawer } from "antd";
import type {
WatermarkConfig,
WatermarkType,
WatermarkPosition,
ScrollDirection,
} from "../types";
import { DEFAULT_WATERMARK } from "../types";
/* ──────────── 常量 ──────────── */
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;
config: WatermarkConfig;
onChange: (config: WatermarkConfig) => void;
}
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;
return (
<Drawer
title="🔖 水印设置"
placement="right"
width={400}
open={open}
onClose={onClose}
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>
{/* ── 无水印提示 ── */}
{config.type === "none" && (
<div className="wp-empty-hint">
<span className="wp-empty-icon">🚫</span>
<p></p>
<p className="wp-empty-desc"></p>
</div>
)}
{/* ── 图片水印配置 ── */}
{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>
)}
{/* ── 文字水印配置 ── */}
{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>
)}
{/* ── 滚动水印配置 ── */}
{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>
)}
{/* ── 通用设置(非 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>
)}
{/* ── 底部操作 ── */}
<div className="wp-footer">
<button className="wp-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default WatermarkPanel;
+529
View File
@@ -5,6 +5,508 @@
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(画中画)
@@ -18,4 +520,31 @@ export interface ClipData {
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;
}
+102 -10
View File
@@ -29,7 +29,9 @@ import {
createEditPlan,
generateEditPlan,
updateEditPlan,
getGenerationTaskResults,
} from "@/api/editPlans";
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
@@ -152,6 +154,9 @@ const GeneratePage: React.FC = () => {
const [progress, setProgress] = useState(0);
const [generated, setGenerated] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [videoUrl, setVideoUrl] = useState<string>("");
const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -197,8 +202,8 @@ const GeneratePage: React.FC = () => {
try {
const plan = await getEditPlan(editPlanId);
if (plan.name) setTitle(plan.name);
const cfg = plan.config as Record<string, unknown>;
if (cfg && Array.isArray(cfg.asset_ids)) {
const cfg = plan.config;
if (cfg?.asset_ids) {
setSelectedMaterials(
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
);
@@ -472,7 +477,13 @@ const GeneratePage: React.FC = () => {
setGenerateError(null);
try {
const voiceConfig: Record<string, unknown> = {};
const voiceConfig: Pick<
EditPlanConfig,
| "voice_id"
| "voice_clone_profile_id"
| "custom_audio_url"
| "custom_text"
> = {};
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined;
} else if (voiceMode === "clone") {
@@ -518,6 +529,25 @@ const GeneratePage: React.FC = () => {
setProgress(100);
setGenerating(false);
setGenerated(true);
// 获取生成的视频结果
if (data.generation_task_id) {
try {
const videos = await getGenerationTaskResults(
data.generation_task_id,
);
setGeneratedVideos(videos);
if (videos.length > 0) {
setVideoUrl(
videos[0].file_url || videos[0].download_url || "",
);
setThumbnailUrl(videos[0].thumbnail_url || "");
}
} catch (err) {
console.error("[获取生成结果失败]", err);
}
}
message.success("视频生成完成!");
return;
}
@@ -537,7 +567,8 @@ const GeneratePage: React.FC = () => {
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -597,7 +628,8 @@ const GeneratePage: React.FC = () => {
const extractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -627,7 +659,8 @@ const GeneratePage: React.FC = () => {
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -708,6 +741,42 @@ const GeneratePage: React.FC = () => {
materialMode,
]);
/* ── 下载视频 ── */
const handleDownload = useCallback(async () => {
if (!generatedVideos.length) return;
const video = generatedVideos[0];
try {
// 优先使用 download_url(签名 URL),回退到 file_url
const url = video.download_url || video.file_url;
if (url) {
const a = document.createElement("a");
a.href = url;
a.download = video.name || "generated-video.mp4";
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
} catch (err) {
console.error("[下载失败]", err);
message.error("下载失败,请重试");
}
}, [generatedVideos]);
/* ── 分享视频 ── */
const handleShare = useCallback(async () => {
if (!generatedVideos.length) return;
const video = generatedVideos[0];
const shareUrl = video.file_url || window.location.href;
try {
await navigator.clipboard.writeText(shareUrl);
message.success("视频链接已复制到剪贴板");
} catch {
// fallback: 显示 URL 让用户手动复制
message.info(`视频链接: ${shareUrl}`);
}
}, [generatedVideos]);
/* ── 步骤导航 ── */
const goNext = useCallback(() => {
if (currentStep === 1 && !selectedTemplate) {
@@ -1724,8 +1793,25 @@ const GeneratePage: React.FC = () => {
<div className="xx-generate-preview">
{/* 视频预览 */}
<div className="xx-preview-video">
{generated ? (
<video src="" controls preload="none" />
{generated && videoUrl ? (
<video
src={videoUrl}
controls
preload="metadata"
poster={thumbnailUrl || undefined}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
) : generated ? (
<div
style={{
textAlign: "center",
padding: 24,
color: "var(--text-secondary)",
}}
>
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<div></div>
</div>
) : (
<button className="xx-play-btn" type="button">
<PlayCircleOutlined />
@@ -1778,12 +1864,18 @@ const GeneratePage: React.FC = () => {
{/* 生成完成后显示下载/分享 */}
{generated && (
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
<button className="xx-btn xx-btn-ghost">
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
<DownloadOutlined />
</button>
<button className="xx-btn xx-btn-ghost">
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
<ShareAltOutlined />
</button>
<button
className="xx-btn xx-btn-ghost"
onClick={() => navigate("/app/products")}
>
</button>
</div>
)}
</div>
+185 -18
View File
@@ -31,7 +31,11 @@ import {
getProducts,
deleteProduct,
getProductDownloadUrl,
updateReviewStatus,
batchDownload,
getBatchDownloadStatus,
type ProductItem as ApiProductItem,
type ReviewStatus,
} from "@/api/products";
import "./products.css";
@@ -53,6 +57,12 @@ interface ProductItem {
fileSize: number; // MB
videoUrl?: string;
thumbnailUrl?: string;
/** 复核状态 */
reviewStatus?: ReviewStatus;
/** 所属项目 ID */
projectId?: string;
/** 所属项目名称 */
projectName?: string;
}
/** 将后端 status 映射为前端 ProductStatus */
@@ -108,6 +118,9 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
videoUrl: item.video_url,
thumbnailUrl: item.thumbnail_url,
reviewStatus: item.review_status,
projectId: item.project_id,
projectName: item.project_name,
});
/* ============================================================
@@ -135,6 +148,30 @@ const formatSize = (mb: number): string => {
return `${mb.toFixed(1)} MB`;
};
/** 复核状态配置 */
const reviewStatusConfig: Record<
ReviewStatus,
{ text: string; className: string }
> = {
pending_review: { text: "待复核", className: "review-pending" },
approved: { text: "已通过", className: "review-approved" },
rejected: { text: "需修改", className: "review-rejected" },
};
/** 复核状态循环顺序 */
const REVIEW_STATUS_CYCLE: ReviewStatus[] = [
"pending_review",
"approved",
"rejected",
];
/** 获取下一个复核状态 */
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
if (!current) return "approved";
const idx = REVIEW_STATUS_CYCLE.indexOf(current);
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length];
};
/* ============================================================
* ProductCard
* ============================================================ */
@@ -148,6 +185,7 @@ const ProductCard: React.FC<{
onShare: (product: ProductItem) => void;
onDelete: (id: string) => void;
onPublish: (product: ProductItem) => void;
onReviewStatusChange: (id: string) => void;
}> = ({
product,
isSelected,
@@ -158,6 +196,7 @@ const ProductCard: React.FC<{
onShare,
onDelete,
onPublish,
onReviewStatusChange,
}) => {
const st = statusConfig[product.status];
@@ -201,6 +240,33 @@ const ProductCard: React.FC<{
{/* 已发布徽章(右上角) */}
{product.isPublished && <div className="xx-product-badge"> </div>}
{/* 复核状态标签(右上角) */}
{product.reviewStatus && (
<div
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
onClick={(e) => {
e.stopPropagation();
onReviewStatusChange(product.id);
}}
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text}`}
>
{reviewStatusConfig[product.reviewStatus].text}
</div>
)}
{/* 无复核状态时显示"待复核"入口 */}
{!product.reviewStatus && (
<div
className="xx-product-review-tag review-pending"
onClick={(e) => {
e.stopPropagation();
onReviewStatusChange(product.id);
}}
title="点击设置复核状态"
>
</div>
)}
{/* 缩略图 */}
<div className="xx-product-thumb">
{product.thumbnailUrl ? (
@@ -517,7 +583,7 @@ const ProductLibrary: React.FC = () => {
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: getProducts,
queryFn: () => getProducts(),
staleTime: 30_000,
});
@@ -536,11 +602,26 @@ const ProductLibrary: React.FC = () => {
},
});
/* ── 复核状态 mutation ── */
const reviewMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
updateReviewStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
message.success("复核状态已更新");
},
onError: () => {
message.error("更新复核状态失败");
},
});
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [filterTime, setFilterTime] = useState<string>("all");
const [filterDuration, setFilterDuration] = useState<string>("all");
const [filterProject, setFilterProject] = useState<string>("all");
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all");
/* 批量操作 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -596,6 +677,20 @@ const ProductLibrary: React.FC = () => {
});
}
/* 项目筛选 */
if (filterProject !== "all") {
list = list.filter((p) => p.projectId === filterProject);
}
/* 复核状态筛选 */
if (filterReviewStatus !== "all") {
if (filterReviewStatus === "none") {
list = list.filter((p) => !p.reviewStatus);
} else {
list = list.filter((p) => p.reviewStatus === filterReviewStatus);
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
@@ -603,7 +698,15 @@ const ProductLibrary: React.FC = () => {
}
return list;
}, [products, filterStatus, filterTime, filterDuration, searchText]);
}, [
products,
filterStatus,
filterTime,
filterDuration,
filterProject,
filterReviewStatus,
searchText,
]);
/* 全选 */
const allSelected =
@@ -679,24 +782,56 @@ const ProductLibrary: React.FC = () => {
message.info("发布功能待后端 API 补齐");
};
/* 批量下载 */
/* 切换复核状态 */
const handleReviewStatusChange = (id: string) => {
const current = products.find((p) => p.id === id)?.reviewStatus;
const nextStatus = getNextReviewStatus(current);
reviewMutation.mutate({ id, status: nextStatus });
};
/* 批量下载 — 使用 batch-download API + 轮询 */
const [batchDownloading, setBatchDownloading] = useState(false);
const handleBatchDownload = async () => {
const ids = Array.from(selectedIds);
let successCount = 0;
for (const id of ids) {
try {
const { url } = await getProductDownloadUrl(id);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
successCount++;
} catch {
// 忽略单个失败
}
if (ids.length === 0) return;
setBatchDownloading(true);
try {
// 发起批量下载任务
const { job_id } = await batchDownload(ids);
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`);
// 轮询下载状态(最多 60 次,每次 2 秒)
let attempts = 0;
const maxAttempts = 60;
const poll = async (): Promise<void> => {
if (attempts >= maxAttempts) {
message.warning("打包超时,请稍后在消息中心查看");
return;
}
attempts++;
await new Promise((r) => setTimeout(r, 2000));
const status = await getBatchDownloadStatus(job_id);
if (status.status === "completed" && status.download_url) {
const a = document.createElement("a");
a.href = status.download_url;
a.download = "";
a.click();
message.success(`已打包下载 ${ids.length} 个视频`);
setSelectedIds(new Set());
} else if (status.status === "failed") {
message.error("批量下载失败,请重试");
} else {
// 继续轮询
await poll();
}
};
await poll();
} catch {
message.error("发起批量下载失败");
} finally {
setBatchDownloading(false);
}
message.success(`已下载 ${successCount}/${ids.length} 个视频`);
setSelectedIds(new Set());
};
/* 批量删除 */
@@ -823,8 +958,9 @@ const ProductLibrary: React.FC = () => {
buttonSize="sm"
icon={<DownloadOutlined />}
onClick={handleBatchDownload}
disabled={batchDownloading}
>
{batchDownloading ? "打包中..." : "批量下载"}
</Button>
<Button
buttonType="primary"
@@ -904,6 +1040,36 @@ const ProductLibrary: React.FC = () => {
{ value: "long", label: ">3分钟" },
]}
/>
<Select
value={filterProject}
onChange={setFilterProject}
style={{ width: 140 }}
options={[
{ value: "all", label: "全部项目" },
...Array.from(
new Map(
products
.filter((p) => p.projectId && p.projectName)
.map((p) => [p.projectId!, p.projectName!] as const),
),
).map(([id, name]) => ({
value: id as string,
label: name as string,
})),
]}
/>
<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
@@ -932,6 +1098,7 @@ const ProductLibrary: React.FC = () => {
onShare={handleShare}
onDelete={handleDelete}
onPublish={handlePublish}
onReviewStatusChange={handleReviewStatusChange}
/>
))}
</div>
+36
View File
@@ -222,6 +222,42 @@
backdrop-filter: blur(4px);
}
/* 复核状态标签(右上角,位于已发布徽章下方) */
.xx-product-review-tag {
position: absolute;
top: 34px;
right: 10px;
z-index: 2;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
backdrop-filter: blur(4px);
cursor: pointer;
transition: var(--transition-all);
user-select: none;
}
.xx-product-review-tag:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
.xx-product-review-tag.review-pending {
background: rgba(156, 163, 175, 0.9);
color: var(--text-inverse);
}
.xx-product-review-tag.review-approved {
background: rgba(16, 185, 129, 0.9);
color: var(--text-inverse);
}
.xx-product-review-tag.review-rejected {
background: rgba(239, 68, 68, 0.9);
color: var(--text-inverse);
}
/* 缩略图区域 */
.xx-product-thumb {
aspect-ratio: 9 / 16;
+450
View File
@@ -0,0 +1,450 @@
/**
*
*
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Table,
Tabs,
Select,
Tag,
Button,
message,
Popconfirm,
Tooltip,
} from "antd";
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
MinusCircleOutlined,
RedoOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getTasks,
retryTask,
type TaskItem,
type TaskStatus,
type TaskListParams,
} from "@/api/tasks";
import "./tasks.css";
/* ──────────── 常量 ──────────── */
/** 状态 Tab 配置 */
const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "waiting", label: "等待中" },
{ key: "running", label: "进行中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
];
/** 类型筛选选项 */
const TYPE_OPTIONS = [
{ value: "all", label: "全部类型" },
{ value: "generation", label: "生成任务" },
{ value: "ingest", label: "素材导入" },
];
/** 状态标签配置 */
const STATUS_CONFIG: Record<
TaskStatus,
{ label: string; color: string; icon: React.ReactNode }
> = {
pending: {
label: "等待中",
color: "default",
icon: <ClockCircleOutlined />,
},
waiting: {
label: "排队中",
color: "processing",
icon: <SyncOutlined spin />,
},
running: {
label: "进行中",
color: "processing",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
cancelled: {
label: "已取消",
color: "default",
icon: <MinusCircleOutlined />,
},
};
/** 任务类型标签 */
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
generation: { label: "生成任务", color: "blue" },
ingest: { label: "素材导入", color: "green" },
};
/* ──────────── 工具函数 ──────────── */
/** 格式化耗时 */
const formatDuration = (seconds?: number): string => {
if (!seconds) return "-";
if (seconds < 60) return `${Math.round(seconds)}`;
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
if (minutes < 60) return `${minutes}${secs}`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}小时${mins}`;
};
/** 格式化时间 */
const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
/* ──────────── 主组件 ──────────── */
export default function TaskCenter() {
const queryClient = useQueryClient();
// 筛选状态
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(
null,
);
// 查询参数
const queryParams: TaskListParams = {
page,
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(typeFilter !== "all" && { task_type: typeFilter }),
};
// 获取任务列表
const { data, isLoading, error } = useQuery({
queryKey: ["tasks", queryParams],
queryFn: () => getTasks(queryParams),
refetchInterval: (query) => {
// 有进行中的任务时自动刷新
const tasks = query.state.data?.items ?? [];
const hasRunning = tasks.some(
(t) => t.status === "running" || t.status === "waiting",
);
return hasRunning ? 5000 : false;
},
});
// 重试任务
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
message.success("任务已重新提交");
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: () => {
message.error("重试失败,请检查任务状态");
},
});
// 展开查看详情
const handleExpand = async (expanded: boolean, record: TaskItem) => {
if (!expanded) {
setExpandedTaskId(null);
setExpandedTaskDetail(null);
return;
}
setExpandedTaskId(record.id);
// 如果是失败任务,获取详情(含 error_info
if (record.status === "failed" && record.error_info) {
setExpandedTaskDetail(record);
}
};
// 表格列定义
const columns: ColumnsType<TaskItem> = [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" };
return <Tag color={config.color}>{config.label}</Tag>;
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
};
return (
<Tag
color={config.color}
icon={config.icon}
className="task-status-tag"
>
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
);
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => (
<span className="task-step">{step || "-"}</span>
),
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => (
<span className="task-duration">{formatDuration(seconds)}</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => (
<span className="task-time">{formatTime(time)}</span>
),
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => retryMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryMutation.isPending}
className="task-retry-btn"
>
</Button>
</Popconfirm>
);
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => {
setExpandedTaskId(record.id);
setExpandedTaskDetail(record);
}}
>
</Button>
);
}
return <span className="task-action-placeholder">-</span>;
},
},
];
// 展开行渲染(错误详情)
const expandedRowRender = (record: TaskItem) => {
const detail = expandedTaskDetail || record;
const errorInfo = detail.error_info;
if (!errorInfo && !detail.error_message) {
return <div className="task-expand-empty"></div>;
}
return (
<div className="task-error-detail">
<div className="task-error-header">
<ExclamationCircleOutlined className="task-error-icon" />
<span></span>
</div>
<div className="task-error-body">
{errorInfo?.error_type && (
<div className="task-error-row">
<span className="task-error-label"></span>
<Tag color="error">{errorInfo.error_type}</Tag>
</div>
)}
{(errorInfo?.error_message || detail.error_message) && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span className="task-error-message">
{errorInfo?.error_message || detail.error_message}
</span>
</div>
)}
{errorInfo?.failed_step && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span>{errorInfo.failed_step}</span>
</div>
)}
{errorInfo?.stack_trace && (
<div className="task-error-row task-error-stack">
<span className="task-error-label"></span>
<pre>{errorInfo.stack_trace}</pre>
</div>
)}
</div>
</div>
);
};
// 错误处理
if (error) {
return (
<div className="task-center">
<div className="task-error">
<CloseCircleOutlined />
<p></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return (
<div className="task-center">
{/* 页面标题 */}
<div className="task-header">
<h1 className="task-title"></h1>
<p className="task-subtitle"></p>
</div>
{/* 筛选栏 */}
<div className="task-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={(key) => {
setStatusFilter(key as TaskStatus | "all");
setPage(1);
}}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="task-status-tabs"
/>
{/* 类型筛选 */}
<div className="task-type-filter">
<Select
value={typeFilter}
onChange={(value) => {
setTypeFilter(value);
setPage(1);
}}
options={TYPE_OPTIONS}
style={{ width: 140 }}
placeholder="选择类型"
/>
</div>
</div>
{/* 任务表格 */}
<Table
columns={columns}
dataSource={data?.items || []}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
expandable={{
expandedRowRender,
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
onExpand: handleExpand,
rowExpandable: (record) =>
record.status === "failed" &&
(!!record.error_info || !!record.error_message),
}}
scroll={{ x: 800 }}
className="task-table"
locale={{
emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
}}
/>
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
/* ──────────── 任务中心 ──────────── */
.task-center {
padding: var(--space-lg);
max-width: 1400px;
margin: 0 auto;
}
/* 页面标题 */
.task-header {
margin-bottom: var(--space-lg);
}
.task-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0 0 var(--space-xs) 0;
}
.task-subtitle {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin: 0;
}
/* 筛选栏 */
.task-filters {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--bg-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.task-status-tabs {
flex: 1;
}
.task-status-tabs .ant-tabs-nav {
margin-bottom: 0;
}
.task-status-tabs .ant-tabs-tab {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
}
.task-type-filter {
flex-shrink: 0;
margin-left: var(--space-md);
}
/* 表格 */
.task-table {
background: var(--bg-surface);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--border-primary);
}
.task-table .ant-table {
background: transparent;
}
.task-table .ant-table-thead > tr > th {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.task-table .ant-table-tbody > tr > td {
border-bottom: 1px solid var(--border-light);
}
.task-table .ant-table-tbody > tr:hover > td {
background: var(--bg-hover);
}
/* 任务 ID */
.task-id {
font-family: var(--font-mono, monospace);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* 状态标签 */
.task-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
}
.task-progress {
font-size: var(--font-size-xs);
opacity: 0.9;
}
/* 当前步骤 */
.task-step {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 耗时 */
.task-duration {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
/* 时间 */
.task-time {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 操作按钮 */
.task-retry-btn {
color: var(--primary-500);
}
.task-retry-btn:hover {
color: var(--primary-600);
}
.task-action-placeholder {
color: var(--text-disabled, var(--text-secondary));
font-size: var(--font-size-sm);
}
/* 展开行 - 错误详情 */
.task-error-detail {
padding: var(--space-md);
background: var(--bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--border-primary);
}
.task-error-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
font-weight: var(--font-weight-semibold);
color: var(--error-500, #ef4444);
}
.task-error-icon {
font-size: var(--font-size-lg);
}
.task-error-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.task-error-row {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
font-size: var(--font-size-sm);
}
.task-error-label {
color: var(--text-secondary);
flex-shrink: 0;
min-width: 80px;
}
.task-error-message {
color: var(--error-500, #ef4444);
word-break: break-all;
}
.task-error-stack {
flex-direction: column;
}
.task-error-stack pre {
margin: var(--space-xs) 0 0 0;
padding: var(--space-sm);
background: var(--bg-surface);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--text-secondary);
overflow-x: auto;
max-height: 200px;
font-family: var(--font-mono, monospace);
}
.task-expand-empty {
padding: var(--space-md);
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* 空状态 */
.task-empty {
padding: var(--space-2xl) 0;
text-align: center;
color: var(--text-secondary);
}
.task-empty .anticon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
.task-empty p {
margin: 0;
font-size: var(--font-size-base);
}
/* 错误状态 */
.task-error {
padding: var(--space-2xl);
text-align: center;
color: var(--error-500, #ef4444);
}
.task-error .anticon {
font-size: 48px;
margin-bottom: var(--space-md);
}
.task-error p {
margin: 0 0 var(--space-md) 0;
font-size: var(--font-size-base);
}
/* 响应式 */
@media (max-width: 768px) {
.task-center {
padding: var(--space-md);
}
.task-filters {
flex-direction: column;
gap: var(--space-md);
}
.task-type-filter {
margin-left: 0;
width: 100%;
}
.task-type-filter .ant-select {
width: 100% !important;
}
.task-status-tabs .ant-tabs-nav {
overflow-x: auto;
}
.task-status-tabs .ant-tabs-tab {
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-xs);
}
}
+422 -319
View File
@@ -1,106 +1,42 @@
/**
* V21
* 2.13 EditPlan
*
* - EditTemplate
* - +
* - EditPlan UI
* - 使 useQuery APIapi/templates.ts, api/editPlans.ts
* API
* - page/page_size/category/keyword/duration_range
* - BGM
* - /
* - + + +
*/
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui";
import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd";
import {
LoadingOutlined,
ExclamationCircleOutlined,
InboxOutlined,
SearchOutlined,
CopyOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import {
getTemplates,
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
type TemplateItem,
type TemplateListParams,
type TemplateSegment,
} from "@/api/templates";
import { createEditPlan } from "@/api/editPlans";
import "./templates.css";
/* ============================================================
* EditTemplate / EditPlan / TemplateClipConfig
*
* ============================================================ */
/** 模板片段配置 */
interface TemplateClipConfig {
id: string;
order: number;
clipType: string;
description: string;
duration: number; // 秒
}
/** 模板类型 */
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog";
/** 模板数据(UI 层,映射自后端 TemplateItem */
interface EditTemplate {
id: string;
name: string;
type: EditTemplateType;
description: string;
usageCount: number;
isFavorite: boolean;
thumbnailGradient: string;
scriptContent: string;
clipConfigs: TemplateClipConfig[];
recommendedDuration: number; // 秒
tags: string[];
}
/* ============================================================
* TemplateItem EditTemplate
* ============================================================ */
/** 根据 category 推断模板类型 */
const inferTemplateType = (category: string): EditTemplateType => {
const map: Record<string, EditTemplateType> = {
: "口播",
: "种草",
: "产品",
: "品牌",
: "混剪",
Vlog: "Vlog",
};
return map[category] ?? "口播";
};
/** 根据 category 生成占位渐变色 */
const gradientForCategory = (category: string): string => {
const gradients: Record<string, string> = {
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
: "linear-gradient(135deg, #10b981, #059669)",
: "linear-gradient(135deg, #0ea5e9, #0284c7)",
: "linear-gradient(135deg, #f59e0b, #d97706)",
: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
};
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
};
/** 将后端 TemplateItem 映射为前端 EditTemplate */
const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
id: item.id,
name: item.name,
type: inferTemplateType(item.category),
description: item.description ?? "",
usageCount: 0,
isFavorite: item.is_favorite ?? false,
thumbnailGradient: gradientForCategory(item.category),
scriptContent: "",
clipConfigs: [],
recommendedDuration: item.target_duration ?? 0,
tags: [item.category],
});
/* ============================================================
*
* ============================================================ */
@@ -120,98 +56,116 @@ const TEMPLATE_TYPES: Array<{
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
];
/** 时长筛选选项 */
const DURATION_OPTIONS: Array<{
value: "" | "short" | "medium" | "long";
label: string;
}> = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
];
/* ============================================================
*
* ============================================================ */
/** 获取类型对应颜色 */
const getTypeColor = (type: EditTemplateType): string => {
const getTypeColor = (type: string): string => {
const found = TEMPLATE_TYPES.find((t) => t.type === type);
return found?.color ?? "#6366f1";
};
/** 获取片段类型标签 */
const getClipTypeLabel = (clipType: string): string => clipType;
/** 获取片段类型颜色 */
const getClipTypeColor = (clipType: string): string => {
const colorMap: Record<string, string> = {
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#f59e0b",
: "#8b5cf6",
: "#ec4899",
: "#14b8a6",
: "#f59e0b",
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#64748b",
: "#f59e0b",
: "#0ea5e9",
: "#8b5cf6",
: "#10b981",
1: "#6366f1",
2: "#8b5cf6",
: "#ef4444",
: "#10b981",
使: "#0ea5e9",
: "#f59e0b",
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#f59e0b",
: "#6366f1",
: "#10b981",
: "#f59e0b",
: "#0ea5e9",
: "#ef4444",
: "#64748b",
: "#ec4899",
: "#f59e0b",
: "#10b981",
: "#0ea5e9",
: "#8b5cf6",
/** 根据 category 生成占位渐变色 */
const gradientForCategory = (category: string): string => {
const gradients: Record<string, string> = {
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
: "linear-gradient(135deg, #10b981, #059669)",
: "linear-gradient(135deg, #0ea5e9, #0284c7)",
: "linear-gradient(135deg, #f59e0b, #d97706)",
: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
};
return colorMap[clipType] ?? "#6366f1";
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
};
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "0秒";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
interface ConfigDisplayFields {
font_size?: string | number;
font_family?: string;
color?: string;
position?: string;
volume?: string | number;
name?: string;
}
/** 格式化配置对象为可读文本 */
const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认";
const c = config as ConfigDisplayFields;
const parts: string[] = [];
if (c.font_size) parts.push(`字号: ${c.font_size}`);
if (c.font_family) parts.push(`字体: ${c.font_family}`);
if (c.color) parts.push(`颜色: ${c.color}`);
if (c.position) parts.push(`位置: ${c.position}`);
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`);
if (c.name) parts.push(String(c.name));
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config);
};
/** 素材类型标签 */
const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
subtitle: "字幕",
null: "不限",
};
/* ============================================================
*
*
* ============================================================ */
interface TemplatePreviewModalProps {
template: EditTemplate;
interface TemplateDetailModalProps {
template: TemplateItem;
isFavorite: boolean;
onClose: () => void;
onToggleFavorite: (id: string) => void;
onUse: (template: EditTemplate) => void;
onUse: (template: TemplateItem) => void;
onCopy: (template: TemplateItem) => void;
}
const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
isFavorite,
onClose,
onToggleFavorite,
onUse,
onCopy,
}) => {
const totalDuration = template.clipConfigs.reduce(
(sum, c) => sum + c.duration,
const segments = template.segments ?? [];
const totalSegmentDuration = segments.reduce(
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
0,
);
return (
<div className="xx-template-modal-overlay" onClick={onClose}>
<div className="xx-template-modal" onClick={(e) => e.stopPropagation()}>
<div
className="xx-template-modal xx-template-modal-wide"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button
className="xx-template-modal-close"
@@ -224,15 +178,23 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
{/* 预览区域 */}
<div
className="xx-template-modal-preview"
style={{ background: template.thumbnailGradient }}
style={{ background: gradientForCategory(template.category) }}
>
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
"📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.category)
?.icon ?? "📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
</div>
{/* 内容区域 */}
@@ -243,12 +205,12 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
<span
className="xx-template-modal-type-badge"
style={{
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon}{" "}
{template.type}
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon}{" "}
{template.category}
</span>
</div>
@@ -256,66 +218,128 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
<p className="xx-template-modal-desc">{template.description}</p>
{/* 标签 */}
<div className="xx-template-modal-tags">
{template.tags.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
{/* 脚本内容 */}
{template.scriptContent && (
<div className="xx-template-modal-section">
<h4>📝 </h4>
<pre className="xx-template-modal-script">
{template.scriptContent}
</pre>
{(template.tags?.length ?? 0) > 0 && (
<div className="xx-template-modal-tags">
{template.tags!.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
)}
{/* 视频结构 */}
{template.clipConfigs.length > 0 && (
{/* 基本信息 */}
<Descriptions
column={2}
size="small"
className="xx-template-modal-desc-table"
items={[
{
key: "duration",
label: "目标时长",
children: formatDuration(template.target_duration),
},
{
key: "clips",
label: "片段数量",
children: `${template.clip_count}`,
},
{
key: "ratio",
label: "视频比例",
children: template.aspect_ratio ?? "16:9",
},
{
key: "usage",
label: "使用次数",
children: `${template.usage_count ?? 0}`,
},
]}
/>
{/* 素材规则(片段配置) */}
{segments.length > 0 && (
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{template.clipConfigs
.sort((a, b) => a.order - b.order)
.map((clip) => (
<div key={clip.id} className="xx-template-modal-clip-item">
{segments
.sort((a, b) => a.segment_order - b.segment_order)
.map((seg: TemplateSegment, idx: number) => (
<div
key={seg.id ?? idx}
className="xx-template-modal-clip-item"
>
<span className="xx-template-modal-clip-order">
#{seg.segment_order}
</span>
<span
className="xx-template-modal-clip-badge"
style={{
color: getClipTypeColor(clip.clipType),
background: `${getClipTypeColor(clip.clipType)}18`,
color: seg.material_type
? getTypeColor(seg.material_type)
: "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
}}
>
{getClipTypeLabel(clip.clipType)}
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
seg.material_type ??
"不限"}
</span>
<span className="xx-template-modal-clip-desc">
{clip.description}
</span>
<span className="xx-template-modal-clip-duration">
{clip.duration}
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip
title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}
>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(totalDuration)}
{template.recommendedDuration !== totalDuration && (
<span>
{" "}
· {formatDuration(template.recommendedDuration)}
</span>
)}
{formatDuration(Math.round(totalSegmentDuration))}
</div>
</div>
)}
{/* 样式配置 */}
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.subtitle_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.title_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">BGM </span>
<span className="xx-template-modal-style-value">
{formatConfig(template.bgm_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{template.aspect_ratio ?? "16:9"}
</span>
</div>
</div>
</div>
{/* 统计信息 */}
<div className="xx-template-modal-stats">
<span>使 {template.usageCount} </span>
<span>使 {template.usage_count ?? 0} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
@@ -326,15 +350,15 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
{/* 操作按钮 */}
<div className="xx-template-modal-actions">
<Button buttonType="ghost" buttonSize="md" onClick={onClose}>
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
</Button>
<Button
buttonType="primary"
buttonSize="md"
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => onUse(template)}
>
使
使
</Button>
</div>
</div>
@@ -348,11 +372,11 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
* ============================================================ */
interface TemplateCardProps {
template: EditTemplate;
template: TemplateItem;
isFavorite: boolean;
onPreview: (template: EditTemplate) => void;
onPreview: (template: TemplateItem) => void;
onToggleFavorite: (id: string, e: React.MouseEvent) => void;
onUse: (template: EditTemplate) => void;
onUse: (template: TemplateItem) => void;
}
const TemplateCard: React.FC<TemplateCardProps> = ({
@@ -366,15 +390,29 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-card" onClick={() => onPreview(template)}>
{/* 缩略图 */}
<div className="xx-template-thumb">
<div
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{(template.description ?? "").slice(0, 80)}...
</div>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-thumb-img"
/>
) : (
<div
className="xx-template-thumb-bg"
style={{ background: gradientForCategory(template.category) }}
>
{(template.description ?? "").slice(0, 80)}
{(template.description ?? "").length > 80 ? "..." : ""}
</div>
)}
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-preview-hint"></div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{formatDuration(template.target_duration)}
</span>
</div>
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={(e) => onToggleFavorite(template.id, e)}
@@ -390,17 +428,22 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<span
className="xx-template-category-pill"
style={{
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
}}
>
{template.type}
{template.category}
</span>
{(template.tags ?? []).slice(0, 2).map((tag) => (
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
{tag}
</Tag>
))}
</div>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {template.usageCount}
使 {template.usage_count ?? 0}
</span>
<button
className="xx-template-use-btn"
@@ -424,98 +467,158 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
const TemplateLibrary: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
// 筛选状态
const [searchText, setSearchText] = useState("");
const [activeType, setActiveType] = useState<EditTemplateType | "全部">(
"全部",
);
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(
const [durationRange, setDurationRange] = useState<
"" | "short" | "medium" | "long"
>("");
const [page, setPage] = useState(1);
const [pageSize] = useState(12);
// 弹窗状态
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
null,
);
const [detailLoading, setDetailLoading] = useState(false);
// ── 获取模板列表 ──
// ── 构建查询参数 ──
const queryParams: TemplateListParams = useMemo(() => {
const params: TemplateListParams = {
page,
page_size: pageSize,
};
if (activeType !== "全部") params.category = activeType;
if (searchText.trim()) params.keyword = searchText.trim();
if (durationRange) params.duration_range = durationRange;
return params;
}, [page, pageSize, activeType, searchText, durationRange]);
// ── 获取模板列表(后端分页 + 筛选) ──
const {
data: apiTemplates = [],
data: templateData,
isLoading,
isError,
error,
} = useQuery<TemplateItem[], Error>({
queryKey: ["templates"],
queryFn: getTemplates,
staleTime: 60_000,
} = useQuery({
queryKey: ["templates", queryParams],
queryFn: () => getTemplates(queryParams),
staleTime: 30_000,
});
// 将后端数据映射为前端 EditTemplate
const templates = useMemo(
() => apiTemplates.map(mapTemplateItemToEditTemplate),
[apiTemplates],
);
const templates = templateData?.items ?? [];
const totalTemplates = templateData?.total ?? 0;
// ── 收藏 mutation ──
const favMutation = useMutation({
mutationFn: toggleFavoriteTemplate,
onSuccess: (_data, templateId) => {
// 乐观更新:刷新模板列表
queryClient.invalidateQueries({ queryKey: ["templates"] });
// 同时更新当前预览(如果有)
if (previewTemplate && previewTemplate.id === templateId) {
setPreviewTemplate((prev) =>
prev ? { ...prev, isFavorite: !prev.isFavorite } : prev,
prev ? { ...prev, is_favorite: !prev.is_favorite } : prev,
);
}
},
});
// ── 创建 EditPlan mutation ──
const createPlanMutation = useMutation({
mutationFn: (params: { template_id: string; name: string }) =>
createEditPlan(params),
// ── 复制模板 mutation ──
const copyMutation = useMutation({
mutationFn: copyTemplate,
onSuccess: (data) => {
message.success(`模板「${data.name}」已复制到「我的模板」`);
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
onError: () => {
message.error("复制模板失败,请稍后重试");
},
});
// ── 从模板生成剪辑计划 mutation ──
const generateMutation = useMutation({
mutationFn: ({ templateId, name }: { templateId: string; name: string }) =>
generateFromTemplate(templateId, { name }),
onSuccess: (data) => {
message.success(`剪辑计划「${data.name}」已创建`);
navigate("/app/edit-plans");
},
onError: () => {
message.error("生成剪辑计划失败,请稍后重试");
},
});
/** 切换收藏 */
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
favMutation.mutate(id);
};
const toggleFavorite = useCallback(
(id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
favMutation.mutate(id);
},
[favMutation],
);
/** 过滤模板 */
const filtered = useMemo(() => {
return templates.filter((t) => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description ?? "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
t.tags.some((tag) =>
tag.toLowerCase().includes(searchText.toLowerCase()),
);
const matchType = activeType === "全部" || t.type === activeType;
return matchSearch && matchType;
});
}, [searchText, activeType, templates]);
/** 按类型分组 */
const groupedTemplates = useMemo(() => {
const groups: Record<string, EditTemplate[]> = {};
for (const tpl of filtered) {
if (!groups[tpl.type]) groups[tpl.type] = [];
groups[tpl.type].push(tpl);
}
return groups;
}, [filtered]);
/** 使用模板 → 创建 EditPlan */
const handleUseTemplate = async (template: EditTemplate) => {
/** 点击卡片 → 获取详情并展示弹窗 */
const handlePreview = useCallback(async (template: TemplateItem) => {
setDetailLoading(true);
setPreviewTemplate(template);
try {
await createPlanMutation.mutateAsync({
template_id: template.id,
const detail = await getTemplate(template.id);
setPreviewTemplate(detail);
} catch {
// 详情加载失败时使用列表数据
message.warning("模板详情加载失败,显示摘要信息");
} finally {
setDetailLoading(false);
}
}, []);
/** 复制模板 */
const handleCopy = useCallback(
(template: TemplateItem) => {
copyMutation.mutate(template.id);
},
[copyMutation],
);
/** 使用模板 → 生成剪辑计划 */
const handleUse = useCallback(
(template: TemplateItem) => {
generateMutation.mutate({
templateId: template.id,
name: `基于「${template.name}」的剪辑计划`,
});
navigate("/app/editing-planner");
} catch (err) {
console.error("[TemplateLibrary] createEditPlan failed:", err);
}
};
},
[generateMutation, navigate],
);
/** 搜索防抖处理 */
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
setPage(1);
},
[],
);
/** 切换分类 */
const handleCategoryChange = useCallback(
(type: EditTemplateType | "全部") => {
setActiveType(type);
setPage(1);
},
[],
);
/** 切换时长筛选 */
const handleDurationChange = useCallback(
(value: "" | "short" | "medium" | "long") => {
setDurationRange(value);
setPage(1);
},
[],
);
// ── Loading 状态 ──
if (isLoading) {
@@ -554,16 +657,12 @@ const TemplateLibrary: React.FC = () => {
<h2></h2>
<p></p>
</div>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/app/editing-planner")}
>
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
+
</Button>
</div>
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
<div className="xx-templates-toolbar">
<div className="xx-templates-search">
<span className="xx-templates-search-icon">
@@ -574,7 +673,7 @@ const TemplateLibrary: React.FC = () => {
type="text"
placeholder="搜索模板名称、描述或标签..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onChange={handleSearchChange}
/>
</div>
<div className="xx-templates-categories">
@@ -582,90 +681,94 @@ const TemplateLibrary: React.FC = () => {
<button
key={cat.type}
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
onClick={() => setActiveType(cat.type)}
onClick={() => handleCategoryChange(cat.type)}
>
<span className="xx-templates-cat-icon">{cat.icon}</span>
{cat.label}
</button>
))}
</div>
{/* 时长筛选 */}
<div className="xx-templates-duration-filter">
{DURATION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
onClick={() => handleDurationChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* ── 模板展示区 ────────────────────────────────────────── */}
{filtered.length === 0 ? (
{templates.length === 0 ? (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
<InboxOutlined />
</div>
<h3>
{searchText || activeType !== "全部"
{searchText || activeType !== "全部" || durationRange
? "未找到匹配的模板"
: "暂无模板"}
</h3>
<p>
{searchText || activeType !== "全部"
{searchText || activeType !== "全部" || durationRange
? "试试调整搜索条件或切换类型"
: "点击上方「创建模板」开始创作"}
</p>
</div>
) : activeType === "全部" ? (
/* 全部类型 → 按类型分组展示 */
<div className="xx-templates-grouped">
{Object.entries(groupedTemplates).map(([type, tpls]) => {
const typeConfig = TEMPLATE_TYPES.find((t) => t.type === type);
return (
<div key={type} className="xx-templates-group">
<div className="xx-templates-group-header">
<span className="xx-templates-group-icon">
{typeConfig?.icon ?? "📋"}
</span>
<h3>{type}</h3>
<span className="xx-templates-group-count">
{tpls.length}
</span>
</div>
<div className="xx-templates-grid">
{tpls.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.isFavorite}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
</div>
);
})}
</div>
) : (
/* 单类型 → 平铺网格 */
<div className="xx-templates-grid">
{filtered.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.isFavorite}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
<>
<div className="xx-templates-grid">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.is_favorite ?? false}
onPreview={handlePreview}
onToggleFavorite={toggleFavorite}
onUse={handleUse}
/>
))}
</div>
{/* 分页 */}
{totalTemplates > pageSize && (
<div className="xx-templates-pagination">
<Pagination
current={page}
pageSize={pageSize}
total={totalTemplates}
showSizeChanger={false}
showQuickJumper
showTotal={(total) => `${total} 个模板`}
onChange={(p) => setPage(p)}
/>
</div>
)}
</>
)}
{/* ── 预览弹窗 ──────────────────────────────────────────── */}
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
{previewTemplate && (
<TemplatePreviewModal
<TemplateDetailModal
template={previewTemplate}
isFavorite={previewTemplate.isFavorite}
isFavorite={previewTemplate.is_favorite ?? false}
onClose={() => setPreviewTemplate(null)}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
onUse={handleUse}
onCopy={handleCopy}
/>
)}
{/* 详情加载中的提示(可选覆盖层) */}
{detailLoading && previewTemplate && (
<div className="xx-template-detail-loading">
<LoadingOutlined /> ...
</div>
)}
</div>
);
};
@@ -130,12 +130,20 @@ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
};
};
/** 配音素材上传元数据(传递给 createAsset 的 metadata */
interface VoiceAssetMetadata {
gender: VoiceGender;
description: string;
duration: number;
[key: string]: unknown;
}
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style */
const buildMetadata = (data: {
gender: VoiceGender;
description: string;
duration?: number;
}): Record<string, unknown> => ({
}): VoiceAssetMetadata => ({
gender: data.gender,
description: data.description,
duration: data.duration || 0,
+10 -2
View File
@@ -623,12 +623,20 @@ const getAudioDuration = (file: File): Promise<number> =>
audio.src = url;
});
/** 音色上传元数据(传递给 createAsset 的 metadata */
interface VoiceUploadMetadata {
gender?: string;
description?: string;
duration?: number;
[key: string]: unknown;
}
const buildVoiceMetadata = (data: {
gender?: string;
description?: string;
duration?: number;
}): Record<string, unknown> => {
const metadata: Record<string, unknown> = {};
}): VoiceUploadMetadata => {
const metadata: VoiceUploadMetadata = {};
if (data.gender) metadata.gender = data.gender;
if (data.description) metadata.description = data.description;
if (data.duration) metadata.duration = Math.round(data.duration);
+14
View File
@@ -135,6 +135,13 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "tasks",
lazy: () =>
import("@/pages/tasks/TaskCenter").then((m) => ({
Component: m.default,
})),
},
{
path: "editing-planner",
lazy: () =>
@@ -149,6 +156,13 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "edit-plans",
lazy: () =>
import("@/pages/edit-plans/EditPlans").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-clone",
lazy: () =>
+697
View File
@@ -0,0 +1,697 @@
"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片.
基于 FFmpeg 实现两种拼接模式
1. **concat demuxerstream copy**最快所有视频编码参数必须一致
2. **concat filter重新编码**更灵活支持不同分辨率/编码/帧率的视频
使用场景
- 多段素材按顺序合并成一个视频
- 视频分割后重新拼接
- 片头 + 正片 + 片尾拼接
降级策略
- 优先尝试 stream copy速度快无质量损失
- 参数不一致时自动降级到 concat filter
- 某段视频失败时跳过不阻断整体拼接
"""
from __future__ import annotations
import logging
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
# concat demuxer 要求一致的参数列表
CONCAT_DEMUXER_REQUIRED_PARAMS = [
"codec_name", # 视频编码
"width", # 宽度
"height", # 高度
"r_frame_rate", # 帧率
"pix_fmt", # 像素格式
"sample_rate", # 音频采样率
"channels", # 音频声道数
"audio_codec", # 音频编码
]
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
@dataclass
class ConcatSegment:
"""单个拼接片段."""
video_path: str # 视频文件路径
start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
has_audio: bool = True # 是否包含音频
@classmethod
def from_dict(cls, seg: dict) -> "ConcatSegment":
"""从字典创建拼接片段,带安全类型转换."""
try:
start_time = max(0.0, float(seg.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(seg.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
video_path=str(seg.get("video_path", "")),
start_time=start_time,
duration=duration,
has_audio=bool(seg.get("has_audio", True)),
)
@dataclass
class ConcatConfig:
"""视频拼接配置."""
segments: list[ConcatSegment] = field(default_factory=list)
output_width: int = 0 # 输出宽度(0=自动取第一段)
output_height: int = 0 # 输出高度(0=自动取第一段)
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
force_reencode: bool = False # 强制重新编码(不用 stream copy
transition: str = "none" # 转场效果(none/crossfade- 预留
transition_duration: float = 0.3 # 转场时长
@classmethod
def from_config_dict(cls, config: dict | None) -> "ConcatConfig":
"""从配置字典创建 ConcatConfig."""
if not config or not isinstance(config, dict):
return cls()
segments_raw = config.get("segments", [])
segments: list[ConcatSegment] = []
if isinstance(segments_raw, list):
for s in segments_raw:
if isinstance(s, dict) and s.get("video_path"):
try:
seg = ConcatSegment.from_dict(s)
if seg.video_path:
segments.append(seg)
except Exception:
logger.warning("[concat] skip invalid segment: %s", s)
continue
try:
output_width = max(0, int(config.get("output_width", 0)))
except (TypeError, ValueError):
output_width = 0
try:
output_height = max(0, int(config.get("output_height", 0)))
except (TypeError, ValueError):
output_height = 0
try:
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
except (TypeError, ValueError):
output_fps = 0.0
return cls(
segments=segments,
output_width=output_width,
output_height=output_height,
output_fps=output_fps,
force_reencode=bool(config.get("force_reencode", False)),
transition=str(config.get("transition", "none")),
transition_duration=max(0.1, float(config.get("transition_duration", 0.3))),
)
@property
def has_effect(self) -> bool:
"""是否有有效片段需要拼接."""
return len([s for s in self.segments if s.video_path]) >= 2
@property
def total_segments(self) -> int:
"""有效片段数量."""
return len([s for s in self.segments if s.video_path])
# ── 路径安全校验 ────────────────────────────────────────────────────────────
def _validate_video_path(video_path: str, work_dir: Path) -> None:
"""校验视频文件路径安全性.
规则
- local:// schema 必须在 work_dir
- 相对路径 必须在 work_dir
- 绝对路径 必须在允许目录白名单内
- 扩展名必须是视频格式
Raises:
PathSecurityError: 路径不安全
"""
if not video_path or not isinstance(video_path, str):
raise PathSecurityError("视频路径不能为空")
# 本地路径(local:// 或相对路径 / 绝对路径)
if video_path.startswith("local://") or not video_path.startswith(("http://", "https://", "oss://")):
is_abs = video_path.startswith("/") and not video_path.startswith("local://")
resolved_path = safe_resolve_path(
video_path,
work_dir,
allow_outside=is_abs,
allowed_extensions=ALLOWED_VIDEO_EXTENSIONS,
)
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
if is_abs:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
path_part = video_path.split("?")[0].split("#")[0]
ext = Path(path_part).suffix.lower()
if ext and ext not in ALLOWED_VIDEO_EXTENSIONS:
raise PathSecurityError(f"不允许的视频文件类型: {ext}")
# ── 视频拼接引擎 ──────────────────────────────────────────────────────────────
class ConcatEngine:
"""视频拼接引擎 — 支持 stream copy 和重新编码两种模式."""
def __init__(self, work_dir: Path):
self.work_dir = work_dir
self.work_dir.mkdir(parents=True, exist_ok=True)
# ── 主入口 ────────────────────────────────────────────────────────
def concat_videos(
self,
config: ConcatConfig,
output_path: Path,
) -> Path:
"""拼接多段视频.
自动选择最优拼接策略
1. 所有片段参数一致 concat demuxerstream copy最快
2. 参数不一致或有裁剪 concat filter重新编码
Args:
config: 拼接配置
output_path: 输出文件路径
Returns:
输出文件路径
"""
valid_segments = [s for s in config.segments if s.video_path]
if not valid_segments:
raise ValueError("No valid video segments to concat")
# ── 安全校验:段数上限 ──
if len(valid_segments) > MAX_CONCAT_SEGMENTS:
raise ValueError(f"Too many concat segments: {len(valid_segments)} > {MAX_CONCAT_SEGMENTS}")
# ── 安全校验:所有视频路径白名单校验 ──
safe_segments = []
for seg in valid_segments:
try:
_validate_video_path(seg.video_path, self.work_dir)
safe_segments.append(seg)
except PathSecurityError as e:
logger.warning("[concat] skip segment: path security check failed: %s", e)
if len(safe_segments) != len(valid_segments):
valid_segments = safe_segments
config.segments = safe_segments
logger.info("[concat] %d segments passed security check", len(safe_segments))
if not valid_segments:
raise ValueError("No valid video segments after security check")
if len(valid_segments) == 1:
# 只有一段,直接复制
import shutil
logger.info("[concat] single segment, copy directly")
shutil.copy2(valid_segments[0].video_path, output_path)
return output_path
# 判断能否用 stream copy
can_stream_copy = self._can_use_stream_copy(config)
if can_stream_copy and not config.force_reencode:
logger.info("[concat] using concat demuxer (stream copy)")
try:
return self._concat_demuxer(config, output_path)
except Exception as e:
logger.warning("[concat] demuxer failed, fallback to filter: %s", e)
# 降级到 concat filter
logger.info("[concat] using concat filter (re-encode)")
return self._concat_filter(config, output_path)
# ── 模式判断 ──────────────────────────────────────────────────────
def _can_use_stream_copy(self, config: ConcatConfig) -> bool:
"""判断是否可以使用 concat demuxerstream copy.
条件
1. 所有视频编码参数一致分辨率帧率编码像素格式
2. 所有音频参数一致采样率声道编码
3. 没有设置 start_time 裁剪或可以通过 concat demuxer inpoint/outpoint 实现
4. 没有强制重新编码
"""
if config.force_reencode:
return False
# 如果有转场效果,必须重新编码
if config.transition != "none":
return False
# 探测所有视频的参数
video_infos = []
for seg in config.segments:
if not seg.video_path:
continue
try:
info = probe_video_info(seg.video_path)
video_infos.append(info)
except Exception:
logger.warning("[concat] probe failed for %s", seg.video_path[-40:])
return False
if len(video_infos) < 2:
return False
# 检查参数一致性
base_info = video_infos[0]
for info in video_infos[1:]:
for param in CONCAT_DEMUXER_REQUIRED_PARAMS:
base_val = base_info.get(param)
curr_val = info.get(param)
if base_val != curr_val:
logger.debug(
"[concat] param mismatch: %s (%s vs %s)",
param,
base_val,
curr_val,
)
return False
# 检查是否有裁剪需求
# concat demuxer 支持 inpoint/outpoint,所以有裁剪也可以用
# 但为了简单和稳定性,有裁剪时也用 filter 模式
# inpoint/outpoint 不是所有格式都支持得好)
has_trimming = any(seg.start_time > 0 or seg.duration > 0 for seg in config.segments if seg.video_path)
if has_trimming:
return False
return True
# ── 模式1concat demuxerstream copy) ──────────────────────────
def _concat_demuxer(self, config: ConcatConfig, output_path: Path) -> Path:
"""使用 concat demuxer 拼接(stream copy.
优点速度极快无质量损失
缺点要求所有视频参数完全一致
"""
# 生成 concat 文件列表
list_file = self.work_dir / "concat_list.txt"
lines = []
for seg in config.segments:
if not seg.video_path:
continue
# 路径转义:单引号替换为 '\''
safe_path = str(seg.video_path).replace("'", "'\\''")
lines.append(f"file '{safe_path}'")
list_file.write_text("\n".join(lines), encoding="utf-8")
command = [
FFMPEG_BIN,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(list_file),
"-c",
"copy",
"-copyts",
str(output_path),
]
logger.info("[concat] demuxer: %d segments", config.total_segments)
run_ffmpeg(command)
return output_path
# ── 模式2concat filter(重新编码) ──────────────────────────────
def _concat_filter(self, config: ConcatConfig, output_path: Path) -> Path:
"""使用 concat filter 拼接(重新编码).
优点支持不同参数的视频支持裁剪
缺点需要重新编码较慢
"""
valid_segments = [s for s in config.segments if s.video_path]
num_segments = len(valid_segments)
# 构建输入参数
input_args: list[str] = []
for seg in valid_segments:
input_args.extend(["-i", seg.video_path])
# 确定输出参数
output_width, output_height, output_fps = self._get_output_params(config)
# 构建 filter_complex
filter_parts: list[str] = []
concat_inputs = ""
for i, seg in enumerate(valid_segments):
vid_label = f"v{i}"
aud_label = f"a{i}"
seg_filters: list[str] = []
# 1. 裁剪(start_time + duration
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
seg_filters.append(f"trim=start={start:.3f}:end={end:.3f}")
else:
seg_filters.append(f"trim=start={start:.3f}")
seg_filters.append("setpts=PTS-STARTPTS")
# 音频同步裁剪
if seg.has_audio:
if seg.duration > 0:
filter_parts.append(
f"[{i}:a]atrim=start={start:.3f}:end={end:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]"
)
else:
filter_parts.append(f"[{i}:a]atrim=start={start:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]")
else:
# 无音频时生成静音轨
filter_parts.append(
f"[{i}:v]trim=start={start:.3f}," f"setpts=PTS-STARTPTS, " f"aevalsrc=0:d={0.1}[{aud_label}]"
)
else:
# 无裁剪,直接用原始标签
if not seg.has_audio:
# 无音频时需要生成静音
try:
dur = probe_duration(seg.video_path)
except Exception:
dur = 10.0
filter_parts.append(f"aevalsrc=0:d={dur:.3f}:s=44100[{aud_label}]")
# 2. 缩放/帧率统一
vf_parts = []
if not seg_filters:
vf_parts.append(f"[{i}:v]")
else:
vf_parts.append("")
# 分辨率统一
if output_width and output_height:
vf_parts.append(
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black"
)
# 帧率统一
if output_fps > 0:
vf_parts.append(f"fps={output_fps}")
# 像素格式统一
vf_parts.append("format=yuv420p")
if len(vf_parts) > 1 or (seg_filters and vf_parts):
if seg_filters:
# 先裁剪后缩放
crop_str = "".join(seg_filters)
scale_str = "".join(vf_parts[1:]) # 跳过空字符串
if scale_str:
filter_parts.append(f"[{i}:v]{crop_str},{scale_str}[{vid_label}]")
else:
filter_parts.append(f"[{i}:v]{crop_str}[{vid_label}]")
else:
filter_parts.append(f"{vf_parts[0]}{''.join(vf_parts[1:])}[{vid_label}]")
else:
if seg_filters:
filter_parts.append(f"[{i}:v]{''.join(seg_filters)}[{vid_label}]")
else:
# 什么都不需要,直接用输入
pass
# 拼接 concat 的输入标签
if seg_filters or (output_width and output_height) or output_fps > 0:
concat_inputs += f"[{vid_label}]"
else:
concat_inputs += f"[{i}:v]"
# 音频标签
if seg.start_time > 0 or seg.duration > 0:
# 已经生成了 aud_label
pass
elif not seg.has_audio:
# 已经生成了静音 aud_label
pass
else:
# 使用原始音频
pass
# 简化处理:用更直接的方式构建 filter
# 重新整理一下,确保所有输入都有对应的 v_i 和 a_i 标签
filter_parts.clear()
concat_inputs = "" # 按段交织: [v0][a0][v1][a1]...
for i, seg in enumerate(valid_segments):
v_label = f"v{i}_in"
a_label = f"a{i}_in"
# 视频处理链
v_steps: list[str] = [f"[{i}:v]"]
# 裁剪
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
v_steps.append(f"trim=start={start:.3f}:end={end:.3f},")
else:
v_steps.append(f"trim=start={start:.3f},")
v_steps.append("setpts=PTS-STARTPTS,")
# 缩放
if output_width and output_height:
v_steps.append(
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black,"
)
# 帧率
if output_fps > 0:
v_steps.append(f"fps={output_fps},")
# 像素格式
v_steps.append("format=yuv420p")
v_filter = "".join(v_steps) + f"[{v_label}]"
filter_parts.append(v_filter)
# 音频处理链
a_steps: list[str] = []
if seg.has_audio:
a_steps.append(f"[{i}:a]")
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
a_steps.append(f"atrim=start={start:.3f}:end={end:.3f},")
else:
a_steps.append(f"atrim=start={start:.3f},")
a_steps.append("asetpts=PTS-STARTPTS,")
a_steps.append("aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo")
else:
# 生成静音音频
try:
dur = probe_duration(seg.video_path)
except Exception:
dur = 10.0
# 减去裁剪
if seg.start_time > 0:
dur = max(0.1, dur - seg.start_time)
if seg.duration > 0 and seg.duration < dur:
dur = seg.duration
a_steps.append(f"aevalsrc=0:d={dur:.3f}:s=44100:c=stereo")
a_filter = "".join(a_steps) + f"[{a_label}]"
filter_parts.append(a_filter)
# 按段交织排列(v_i, a_i),这是 FFmpeg concat filter 要求的顺序
concat_inputs += f"[{v_label}][{a_label}]"
# concat filter: 输入按 [v0][a0][v1][a1]... 顺序
filter_parts.append(f"{concat_inputs}" f"concat=n={num_segments}:v=1:a=1[vout][aout]")
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
str(output_path),
]
logger.info(
"[concat] filter: %d segments, %dx%d, %.2f fps",
num_segments,
output_width,
output_height,
output_fps,
)
run_ffmpeg(command)
return output_path
# ── 辅助方法 ──────────────────────────────────────────────────────
def _get_output_params(self, config: ConcatConfig) -> tuple[int, int, float]:
"""获取输出参数(宽、高、帧率).
优先级
1. config 中显式指定的
2. 第一段视频的参数
"""
valid_segments = [s for s in config.segments if s.video_path]
width = config.output_width
height = config.output_height
fps = config.output_fps
# 如果没有显式指定,用第一段的参数
if (width == 0 or height == 0 or fps == 0) and valid_segments:
try:
info = probe_video_info(valid_segments[0].video_path)
if width == 0:
width = int(info.get("width", 1080))
if height == 0:
height = int(info.get("height", 1920))
if fps == 0:
fps_str = info.get("r_frame_rate", "30/1")
if "/" in str(fps_str):
num, den = str(fps_str).split("/")
try:
fps = float(num) / float(den)
except (ValueError, ZeroDivisionError):
fps = 30.0
else:
fps = float(fps_str) if fps_str else 30.0
except Exception:
# 探测失败,用默认值
if width == 0:
width = 1080
if height == 0:
height = 1920
if fps == 0:
fps = 30.0
return width, height, fps
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def concat_video_files(
video_paths: list[str],
output_path: Path,
*,
work_dir: Path | None = None,
force_reencode: bool = False,
) -> Path:
"""简单拼接多个视频文件.
Args:
video_paths: 视频文件路径列表
output_path: 输出路径
work_dir: 工作目录默认输出文件所在目录
force_reencode: 是否强制重新编码
Returns:
输出文件路径
"""
if work_dir is None:
work_dir = output_path.parent
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
engine = ConcatEngine(work_dir)
return engine.concat_videos(config, output_path)
def concat_videos_from_config(
config_dict: dict | None,
output_path: Path,
*,
work_dir: Path,
) -> Path | None:
"""从配置字典执行视频拼接.
降级策略配置无效或拼接失败时返回 None.
"""
config = ConcatConfig.from_config_dict(config_dict)
if not config.has_effect:
return None
try:
engine = ConcatEngine(work_dir)
return engine.concat_videos(config, output_path)
except Exception as e:
logger.error("[concat] concat failed: %s", e)
return None
+26 -27
View File
@@ -1,23 +1,28 @@
"""FFmpeg 工具函数 — 共享原语.
"""FFmpeg 工具函数 — Worker 层.
提供 FFmpeg / FFprobe 调用视频信息探测视频标准化xfade 转场滤镜构建
底层能力 UnifiedRenderServiceVideoComposeService 等复用
业务相关的滤镜构建视频探测视频标准化等能力放在这里
底层原语run_ffmpeg / 二进制路径 / 默认超时已下沉到 packages/shared/ffmpeg_utils.py
本模块 re-export 保持向后兼容
"""
from __future__ import annotations
import logging
import shutil
import subprocess # nosec B404
from pathlib import Path
from typing import Any
# 底层原语从 shared 层导入,application 层和 worker 层共用同一份实现
from shared.ffmpeg_utils import ( # noqa: F401
DEFAULT_FFMPEG_TIMEOUT,
FFMPEG_BIN,
FFPROBE_BIN,
run_ffmpeg,
)
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
# ── 常量Worker 层业务相关) ────────────────────────────────────────────────
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
@@ -61,34 +66,29 @@ XFADE_TRANSITION_MAP: dict[str, str] = {
DEFAULT_TRANSITION_DURATION = 0.5
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致 worker 永久阻塞
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
DEFAULT_FFMPEG_TIMEOUT = 1800
# ── FFprobe 探测 ──────────────────────────────────────────────────────────────
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
def run_ffmpeg(
def run_ffprobe(
command: list[str],
*,
capture_output: bool = True,
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
timeout: int = 30,
) -> tuple[str, str]:
"""执行 FFmpeg 命令。
"""执行 FFprobe 命令。
Args:
command: 完整的 ffmpeg 命令列表 "ffmpeg" 本身
command: 完整的 ffprobe 命令列表 "ffprobe" 本身
capture_output: 是否捕获 stdout/stderr
timeout: 超时时间默认 1800s30分钟None 表示不设超时不推荐
timeout: 超时时间默认 30sNone 表示不设超时
Returns:
(stdout, stderr) 元组
Raises:
subprocess.CalledProcessError: 命令执行失败时抛出
异常信息包含完整 stderr 以便排查
subprocess.TimeoutExpired: 超时未完成时抛出FFmpeg 进程会被 kill
subprocess.CalledProcessError: 命令执行失败时抛出
subprocess.TimeoutExpired: 超时未完成时抛出
"""
try:
result = subprocess.run( # nosec B603
@@ -102,19 +102,18 @@ def run_ffmpeg(
return (result.stdout or "", result.stderr or "")
except subprocess.TimeoutExpired:
logger.error(
"FFmpeg 命令超时 (%ds): command=%s",
"FFprobe 命令超时 (%ds): command=%s",
timeout or -1,
" ".join(str(c) for c in command[:20]),
)
raise
except subprocess.CalledProcessError as e:
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
stderr_text = (e.stderr or "").strip()
logger.error(
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
"FFprobe 命令失败: exit_code=%d command=%s\nstderr:\n%s",
e.returncode,
" ".join(str(c) for c in command[:20]), # 截断过长的命令
stderr_text[:5000], # 截断过长的 stderr
" ".join(str(c) for c in command[:20]),
stderr_text[:5000],
)
raise
+478
View File
@@ -0,0 +1,478 @@
"""多轨道混音引擎 — 支持多路音频独立音量调节与混合.
基于 FFmpeg amix / amerge 实现
- 支持任意数量音频轨道原音BGM配音音效等
- 每轨独立音量调节
- 每轨独立淡入淡出
- 每轨独立时间偏移delay
- 总输出音量归一化补偿
作为 render_audio.py 的增强模块 mix_audio 后处理阶段被调用
bgm_mixer.py 的关系
- bgm_mixer 专注 BGM 单轨道的复杂处理循环人声闪避
- 本模块专注多路轨道的统一音量调节与混合
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
if TYPE_CHECKING:
from video_processing.render_audio import RenderContext
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
TRACK_TYPE_BGM = "bgm" # 背景音乐
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
TRACK_TYPE_SFX = "sfx" # 音效
TRACK_TYPE_AMBIENT = "ambient" # 环境音
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
# 各轨道默认音量(相对主音频)
DEFAULT_VOLUMES = {
TRACK_TYPE_MAIN: 1.0,
TRACK_TYPE_BGM: 0.3,
TRACK_TYPE_VOICEOVER: 1.0,
TRACK_TYPE_SFX: 0.7,
TRACK_TYPE_AMBIENT: 0.2,
}
@dataclass
class AudioTrack:
"""单条音频轨道配置."""
track_id: str # 轨道唯一标识
track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient
audio_path: str # 音频文件路径
volume: float = 1.0 # 音量 0.0 ~ 2.0
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
duration: float = 0.0 # 持续时长(0表示到文件末尾)
enabled: bool = True # 是否启用
@classmethod
def from_dict(cls, track: dict) -> "AudioTrack":
"""从字典创建 AudioTrack,带安全类型转换."""
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
try:
volume = float(track.get("volume", default_vol))
except (TypeError, ValueError):
volume = default_vol
volume = max(0.0, min(2.0, volume))
try:
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
except (TypeError, ValueError):
fade_in = 0.0
try:
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
except (TypeError, ValueError):
fade_out = 0.0
try:
start_time = max(0.0, float(track.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(track.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
track_id=str(track.get("track_id", "")),
track_type=track_type,
audio_path=str(track.get("audio_path", "")),
volume=volume,
fade_in=fade_in,
fade_out=fade_out,
start_time=start_time,
duration=duration,
enabled=bool(track.get("enabled", True)),
)
@dataclass
class MultiTrackMixConfig:
"""多轨道混音配置."""
tracks: list[AudioTrack] = field(default_factory=list)
master_volume: float = 1.0 # 主输出音量
normalize: bool = True # 是否自动归一化补偿
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
@classmethod
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
"""从 plan.config.audio_tracks 字典创建配置."""
if not config or not isinstance(config, dict):
return cls()
tracks_raw = config.get("tracks", [])
tracks: list[AudioTrack] = []
if isinstance(tracks_raw, list):
for t in tracks_raw:
if isinstance(t, dict) and t.get("audio_path"):
try:
track = AudioTrack.from_dict(t)
if track.enabled and track.audio_path:
tracks.append(track)
except Exception:
logger.warning("[multi-track] skip invalid track config: %s", t)
continue
try:
master_volume = float(config.get("master_volume", 1.0))
master_volume = max(0.0, min(2.0, master_volume))
except (TypeError, ValueError):
master_volume = 1.0
return cls(
tracks=tracks,
master_volume=master_volume,
normalize=bool(config.get("normalize", True)),
max_output_volume=float(config.get("max_output_volume", 1.5)),
)
@property
def has_effect(self) -> bool:
"""是否有有效轨道需要混音."""
return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0
# ── 路径安全校验 ────────────────────────────────────────────────────────────
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
"""校验音频文件路径安全性.
规则
- local:// schema 必须在 work_dir
- 相对路径 必须在 work_dir
- 绝对路径 必须在允许目录白名单内
- 扩展名必须是音频格式
Raises:
PathSecurityError: 路径不安全
"""
if not audio_path or not isinstance(audio_path, str):
raise PathSecurityError("音频路径不能为空")
# 本地路径(local:// 或相对路径)
if audio_path.startswith("local://") or not audio_path.startswith(("http://", "https://", "oss://")):
is_abs = audio_path.startswith("/") and not audio_path.startswith("local://")
resolved_path = safe_resolve_path(
audio_path,
work_dir,
allow_outside=is_abs,
allowed_extensions=ALLOWED_AUDIO_EXTENSIONS,
)
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
if is_abs:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
# URL路径,检查扩展名白名单(取 ? 之前的部分)
path_part = audio_path.split("?")[0].split("#")[0]
from pathlib import Path as _P
ext = _P(path_part).suffix.lower()
if ext and ext not in ALLOWED_AUDIO_EXTENSIONS:
raise PathSecurityError(f"不允许的音频文件类型: {ext}")
# ── 单轨道预处理 ────────────────────────────────────────────────────────────
def _prepare_single_track(
ctx: "RenderContext",
track: AudioTrack,
target_duration: float,
output_path: Path,
) -> bool:
"""预处理单条轨道:音量 + 淡入淡出 + 时间偏移 + 截断.
生成一个精确对齐时间轴的音频文件后续统一 amix 混音
Returns:
True 表示处理成功False 表示失败跳过
"""
try:
audio_dur = probe_duration(track.audio_path)
except Exception:
logger.warning("[multi-track] probe failed, skip track: %s", track.track_id)
return False
if audio_dur <= 0:
return False
# 计算实际有效时长
effective_start = track.start_time
if track.duration > 0:
effective_dur = min(track.duration, audio_dur)
else:
effective_dur = audio_dur
# 如果轨道完全在视频时长之外,跳过
if effective_start >= target_duration:
return False
if effective_start + effective_dur <= 0:
return False
# 构建滤镜链
filter_parts: list[str] = []
# 1. 先截断到有效范围
trim_start = 0.0 # 从源文件的哪个位置开始取
if effective_start < 0:
trim_start = -effective_start
effective_start = 0.0
# 实际需要的源时长
need_dur = min(effective_dur, target_duration - effective_start)
if need_dur <= 0:
return False
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_dur:.3f}")
filter_parts.append("asetpts=N/SR/TB")
# 2. 音量调节
if abs(track.volume - 1.0) > 0.001:
filter_parts.append(f"volume={track.volume:.3f}")
# 3. 淡入
if track.fade_in > 0 and track.fade_in < need_dur:
filter_parts.append(f"afade=t=in:st=0:d={track.fade_in:.3f}")
# 4. 淡出
if track.fade_out > 0 and track.fade_out < need_dur:
fade_start = need_dur - track.fade_out
if fade_start > 0:
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={track.fade_out:.3f}")
# 5. 时间偏移(用 adelay 实现开头静音填充)
if effective_start > 0.01:
delay_ms = int(effective_start * 1000)
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
# 6. 最终截断到目标总时长
filter_parts.append(f"atrim=0:{target_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB")
filter_str = ",".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
"-i",
track.audio_path,
"-filter:a",
filter_str,
"-c:a",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"[multi-track] prepare track: id=%s type=%s vol=%.2f start=%.2f dur=%.2f",
track.track_id,
track.track_type,
track.volume,
effective_start,
need_dur,
)
try:
run_ffmpeg(command)
return True
except Exception as e:
logger.warning("[multi-track] track prepare failed: %s, error=%s", track.track_id, e)
return False
# ── 多轨道混音主入口 ─────────────────────────────────────────────────────────
def mix_multi_track(
ctx: "RenderContext",
main_audio_path: Path,
config: MultiTrackMixConfig,
target_duration: float,
) -> Path:
"""多轨道混音:主音频 + 多条附加轨道.
Args:
ctx: 渲染上下文
main_audio_path: 主音频文件路径原音
config: 多轨道混音配置
target_duration: 目标总时长
Returns:
混音后的音频文件路径
"""
output_path = ctx.work_dir / f"multi_track_mix_{ctx.plan_id}.aac"
if target_duration <= 0:
target_duration = 5.0
# ── 安全校验:轨道数量上限 ──
enabled_tracks = [t for t in config.tracks if t.enabled and t.audio_path]
if len(enabled_tracks) > MAX_AUDIO_TRACKS:
logger.warning(
"[multi-track] too many tracks: %d > %d, truncating to max",
len(enabled_tracks),
MAX_AUDIO_TRACKS,
)
enabled_tracks = enabled_tracks[:MAX_AUDIO_TRACKS]
# 更新 config.tracks 为截断后的列表
config.tracks = enabled_tracks
# ── 安全校验:所有音频路径白名单校验 ──
# 主音频路径
try:
_validate_audio_path(str(main_audio_path), ctx.work_dir)
except PathSecurityError as e:
logger.error("[multi-track] main audio path security check failed: %s", e)
raise
# 各轨道音频路径
valid_tracks = []
for track in enabled_tracks:
try:
_validate_audio_path(track.audio_path, ctx.work_dir)
valid_tracks.append(track)
except PathSecurityError as e:
logger.warning("[multi-track] skip track %s: path security check failed: %s", track.track_id, e)
if len(valid_tracks) != len(enabled_tracks):
config.tracks = valid_tracks
logger.info("[multi-track] %d tracks passed security check", len(valid_tracks))
# 收集所有有效轨道(已预处理好的)
prepared_tracks: list[Path] = []
# 主音频作为第0轨
prepared_tracks.append(main_audio_path)
# 预处理每条附加轨道
for i, track in enumerate(config.tracks):
if not track.enabled or not track.audio_path:
continue
track_out = ctx.work_dir / f"track_{i}_{ctx.plan_id}.aac"
if _prepare_single_track(ctx, track, target_duration, track_out):
prepared_tracks.append(track_out)
# 如果只有主音频,直接返回(无需混音)
if len(prepared_tracks) <= 1:
import shutil
shutil.copy2(main_audio_path, output_path)
return output_path
# 使用 amix 混音
num_inputs = len(prepared_tracks)
# 构建输入参数
input_args: list[str] = []
for tp in prepared_tracks:
input_args.extend(["-i", str(tp)])
# amix 的 duration=first 以第一个输入(主音频)时长为准
# normalize 补偿:amix 会把每路音量除以 N,需要乘回来
# 但如果所有轨道都同时有声,可能会爆音,所以用 master_volume 控制
if config.normalize:
# 经验值:不是所有轨道都同时有声,补偿系数取 N * 0.7
compensate = num_inputs * 0.7
else:
compensate = 1.0
final_volume = compensate * config.master_volume
final_volume = min(final_volume, config.max_output_volume)
# 构建 filter_complex
inputs_label = "".join(f"[{i}:a]" for i in range(num_inputs))
filter_complex = (
f"{inputs_label}amix=inputs={num_inputs}:duration=first:dropout_transition=0[outa];"
f"[outa]volume={final_volume:.3f}[final]"
)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
"[final]",
"-c:a",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"[multi-track] mix %d tracks, master_vol=%.2f compensate=%.2f final_vol=%.2f",
num_inputs,
config.master_volume,
compensate,
final_volume,
)
try:
run_ffmpeg(command)
except Exception as e:
logger.error("[multi-track] mix failed, fallback to main audio only: %s", e)
import shutil
shutil.copy2(main_audio_path, output_path)
return output_path
# ── 便捷函数:从 plan.config 快速混音 ───────────────────────────────────────
def mix_audio_tracks_from_config(
ctx: "RenderContext",
main_audio_path: Path,
audio_tracks_config: dict | None,
target_duration: float,
) -> Path:
"""从 plan.config.audio_tracks 配置执行多轨道混音.
降级策略配置无效或混音失败时返回主音频
"""
config = MultiTrackMixConfig.from_config_dict(audio_tracks_config)
if not config.has_effect:
return main_audio_path
return mix_multi_track(ctx, main_audio_path, config, target_duration)
+48 -9
View File
@@ -220,25 +220,64 @@ def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
"""从 asset_id 解析到本地文件路径。
策略按优先级
1. 如果 asset_id 是本地绝对路径/var/storage/... 直接返回
1. 如果 asset_id 是本地绝对路径/var/storage/... 安全校验后返回
2. 如果 work_dir 下已有缓存文件 返回缓存路径
3. OSS 下载到 work_dir/{hash}.mp4 返回下载路径
4. 下载失败 返回 None
缓存策略 asset_id SHA256 16 位为文件名避免重复下载
"""
# 1. 本地绝对路径
if asset_id.startswith("/") and os.path.exists(asset_id):
return Path(asset_id)
# 2. 缓存命中
安全
- 本地绝对路径必须在 ASSET_ALLOWED_DIRS 环境变量指定的目录内
- 文件名经过 sanitize防止路径遍历
- 禁止空字节控制字符
"""
from video_processing.path_security import (
PathSecurityError,
get_allowed_local_dirs,
is_in_allowed_dirs,
sanitize_filename,
)
if not asset_id or not isinstance(asset_id, str):
return None
# 空字节检测
if "\x00" in asset_id:
logger.warning("asset_id 包含空字节,拒绝: %s", asset_id[:50])
return None
# 1. 本地绝对路径 — 必须在允许的目录内
if asset_id.startswith("/") and os.path.exists(asset_id):
try:
resolved = Path(asset_id).resolve()
if is_in_allowed_dirs(resolved, get_allowed_local_dirs()):
return resolved
else:
logger.warning(
"本地素材路径不在允许目录内,拒绝: %s (allowed=%s)",
asset_id[:80],
get_allowed_local_dirs(),
)
return None
except (OSError, PathSecurityError):
return None
# 2. 缓存命中(使用 hash 而非原始 ID,防止路径遍历)
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
cached_path = work_dir / f"{cache_hash}.mp4"
safe_name = sanitize_filename(cache_hash)
cached_path = work_dir / f"{safe_name}.mp4"
if cached_path.exists() and cached_path.stat().st_size > 0:
return cached_path
# 3. 从 OSS 下载
if download_asset(asset_id, cached_path):
# 3. 从 OSS 下载(先标准化 key,防止路径遍历注入)
safe_key = normalize_storage_key(asset_id)
# 额外校验:存储键不能包含 ../ 或绝对路径
if ".." in safe_key or safe_key.startswith("/"):
logger.warning("asset_id 包含路径遍历模式,拒绝下载: %s", asset_id[:80])
return None
if download_asset(safe_key, cached_path):
return cached_path
return None
@@ -0,0 +1,303 @@
"""路径安全校验工具 — 路径遍历防护.
统一的文件路径安全校验方案覆盖所有渲染管线中的路径处理场景
- 本地素材路径校验
- local:// 路径 schema 校验
- 工作目录内路径安全约束
- 防止路径遍历攻击 (../)
防护要点
1. 所有用户可控路径必须在允许的目录内
2. 解析符号链接后的真实路径仍需在允许目录内
3. 禁止空路径相对路径遍历绝对路径逃逸
4. 路径字符限制与规范化
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
# 最大路径长度
MAX_PATH_LENGTH = 4096
# 允许的文件扩展名(渲染相关)
ALLOWED_MEDIA_EXTENSIONS = {
".mp4",
".mov",
".avi",
".mkv",
".webm",
".flv",
".wmv", # 视频
".mp3",
".wav",
".aac",
".ogg",
".flac",
".m4a",
".wma", # 音频
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".webp",
".tiff", # 图片
".srt",
".ass",
".vtt",
".sub", # 字幕
".txt",
".json", # 文本/配置
}
# local:// schema 前缀
LOCAL_SCHEMA_PREFIX = "local://"
class PathSecurityError(ValueError):
"""路径安全校验失败."""
pass
def safe_resolve_path(
input_path: str | Path,
base_dir: str | Path,
*,
allow_outside: bool = False,
allowed_extensions: set[str] | None = None,
) -> Path:
"""安全解析路径,确保最终路径在 base_dir 内.
Args:
input_path: 输入路径相对或绝对
base_dir: 基路径目录解析后的路径必须在此目录内
allow_outside: 是否允许路径在 base_dir 默认禁止
allowed_extensions: 允许的文件扩展名集合None 表示不限制
Returns:
解析后的绝对路径 Path 对象
Raises:
PathSecurityError: 路径不安全
"""
if input_path is None:
raise PathSecurityError("路径不能为空")
path_str = str(input_path).strip()
if not path_str:
raise PathSecurityError("路径不能为空")
if len(path_str) > MAX_PATH_LENGTH:
raise PathSecurityError(f"路径过长 ({len(path_str)} > {MAX_PATH_LENGTH})")
# 空字节检测(必须在 Path() 之前)
if "\x00" in path_str:
raise PathSecurityError("路径包含空字节")
# 处理 local:// schema
if path_str.startswith(LOCAL_SCHEMA_PREFIX):
path_str = path_str[len(LOCAL_SCHEMA_PREFIX) :]
# local:// 后必须是相对路径(相对于 base_dir),不能是绝对路径
if os.path.isabs(path_str):
raise PathSecurityError("local:// 路径不能是绝对路径")
# 规范化 base_dir
base_dir = Path(base_dir).resolve()
if not base_dir.is_dir():
raise PathSecurityError(f"基路径不是有效目录: {base_dir}")
# 解析输入路径
input_path_obj = Path(path_str)
# 如果是绝对路径且不允许外部路径
if input_path_obj.is_absolute() and not allow_outside:
raise PathSecurityError("禁止使用绝对路径(需在工作目录内)")
# 组合并解析为绝对路径
if input_path_obj.is_absolute():
full_path = input_path_obj.resolve()
else:
full_path = (base_dir / input_path_obj).resolve()
# 检查路径遍历 — 确保最终路径在 base_dir 内
if not allow_outside:
try:
full_path.relative_to(base_dir)
except ValueError:
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
# 扩展名校验
if allowed_extensions is not None:
ext = full_path.suffix.lower()
if ext and ext not in allowed_extensions:
raise PathSecurityError(f"不允许的文件类型: {ext}")
# 检查危险路径模式
_check_dangerous_patterns(full_path)
return full_path
def _check_dangerous_patterns(path: Path) -> None:
"""检查危险路径模式."""
path_str = str(path)
# 检查空字节
if "\x00" in path_str:
raise PathSecurityError("路径包含空字节")
# 检查特殊设备文件(Linux
dangerous_prefixes = [
"/proc/",
"/sys/",
"/dev/",
"/etc/passwd",
"/etc/shadow",
"/root/",
"/boot/",
"/var/run/",
]
for prefix in dangerous_prefixes:
if path_str.startswith(prefix):
raise PathSecurityError(f"禁止访问系统路径: {prefix}")
def is_path_safe(
input_path: str | Path,
base_dir: str | Path,
*,
allow_outside: bool = False,
) -> bool:
"""便捷函数:检查路径是否安全,不抛异常."""
try:
safe_resolve_path(input_path, base_dir, allow_outside=allow_outside)
return True
except PathSecurityError:
return False
def validate_local_schema_path(
schema_path: str,
work_dir: str | Path,
) -> Path:
"""校验 local:// schema 路径,返回安全的本地路径.
local:// 路径规则
- 必须以 local:// 开头
- 后面必须是相对路径
- 最终解析后必须在 work_dir
- 不允许 ../ 遍历
Args:
schema_path: local:// 开头的路径
work_dir: 工作目录
Returns:
解析后的安全路径
Raises:
PathSecurityError: 路径不安全
"""
if not schema_path.startswith(LOCAL_SCHEMA_PREFIX):
raise PathSecurityError(f"路径必须以 {LOCAL_SCHEMA_PREFIX} 开头")
return safe_resolve_path(schema_path, work_dir, allow_outside=False)
def sanitize_filename(filename: str) -> str:
"""清理文件名,移除危险字符.
保留字母数字下划线连字符中文字符
移除路径分隔符控制字符特殊符号等
"""
import re
if not filename:
return "unnamed"
# 移除路径分隔符和危险字符
# 保留: 字母数字、中文字符、下划线、连字符、点、空格
sanitized = re.sub(r'[\\/\x00-\x1f\x7f<>:"|?*]', "_", filename)
# 移除开头的点和连续的点(防止隐藏文件和路径遍历)
while sanitized.startswith("."):
sanitized = sanitized[1:]
# 限制长度
if len(sanitized) > 255:
name, ext = os.path.splitext(sanitized)
sanitized = name[: 255 - len(ext)] + ext
# 空文件名兜底
if not sanitized or sanitized == ".":
sanitized = "unnamed"
return sanitized
# ── 允许目录配置 ──────────────────────────────────────────────────────────────
def get_allowed_local_dirs() -> list[Path]:
"""获取允许的本地素材目录列表(从环境变量读取).
环境变量 ASSET_ALLOWED_DIRS多个目录用冒号分隔Linux或分号分隔Windows
默认包含 /tmp
用于
- resolve_asset_path 本地绝对路径白名单
- PiP local_path 类型白名单
- 贴纸本地路径白名单
"""
env_dirs = os.environ.get("ASSET_ALLOWED_DIRS", "")
dirs: list[Path] = []
if env_dirs:
import re
sep = ";" if os.name == "nt" else ":"
for d in re.split(f"[{sep}]", env_dirs):
d = d.strip()
if d:
try:
dirs.append(Path(d).resolve())
except OSError:
pass
# 默认允许 /tmp
if not dirs:
try:
dirs.append(Path("/tmp").resolve()) # nosec B108
except OSError:
pass
return dirs
def is_in_allowed_dirs(path: str | Path, allowed_dirs: list[Path] | None = None) -> bool:
"""检查路径是否在允许的目录列表内.
Args:
path: 待检查的路径
allowed_dirs: 允许的目录列表None 则使用默认配置
Returns:
True 表示在允许目录内
"""
if allowed_dirs is None:
allowed_dirs = get_allowed_local_dirs()
try:
resolved = Path(path).resolve()
for allowed in allowed_dirs:
try:
resolved.relative_to(allowed)
return True
except ValueError:
continue
return False
except OSError:
return False
+32 -6
View File
@@ -465,18 +465,44 @@ class PiPEngine:
layer: PiPLayerConfig,
asset_path_map: dict[str, Path],
) -> Path | None:
"""验证图层素材是否可用,返回本地路径或None(降级跳过)."""
"""验证图层素材是否可用,返回本地路径或None(降级跳过).
安全
- local_path 类型必须在允许的目录内防止路径遍历
- url 类型必须通过 SSRF 安全校验
"""
from video_processing.path_security import is_in_allowed_dirs
from video_processing.url_security import UrlSecurityError, validate_url_safety
try:
if layer.source_type == "local_path":
path = Path(layer.source)
if path.exists():
return path
if not layer.source:
return None
# 路径安全校验:必须在允许目录内
src_path = Path(layer.source)
if not src_path.exists():
return None
if not is_in_allowed_dirs(src_path):
logger.warning(
"PiP local_path 不在允许目录内,拒绝: %s",
layer.source[:80],
)
return None
return src_path.resolve()
elif layer.source_type == "asset_id":
if layer.source in asset_path_map:
return asset_path_map[layer.source]
return None
elif layer.source_type == "url":
# URL类型由调用者负责下载,这里返回标记
return None # 暂时不支持直接URL
# URL类型:先做SSRF安全校验,由调用者负责实际下载
try:
validate_url_safety(layer.source, purpose="pip_source")
logger.info("PiP URL 安全校验通过: %s", layer.source[:80])
except UrlSecurityError as e:
logger.warning("PiP URL 安全校验失败: %s (error=%s)", layer.source[:80], e)
return None
# 暂时不支持直接URL下载,返回None表示降级跳过
return None
except Exception as e:
logger.warning("PiP素材验证失败: %s", e)
+110 -29
View File
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_has_audio, run_ffmpeg
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
from video_processing.speed_engine import SpeedEngine
if TYPE_CHECKING:
from video_processing.unified_render_service import RenderLayer, ResolvedClip
@@ -76,6 +77,7 @@ def mix_audio(
*,
bgm_path: str | None = None,
bgm_config: dict | None = None,
audio_tracks_config: dict | None = None,
) -> Path | None:
"""音频后处理混音.
@@ -86,6 +88,8 @@ def mix_audio(
4. 输出时长截断到 video_duration
5. 无音频流的 clip 会被自动跳过避免 FFmpeg 引用 [i:a] 失败
6. 如果提供了 bgm_path则额外混入 BGM支持淡入淡出循环人声闪避
7. 如果配置了 audio_tracks则混入多轨道音频配音音效等
8. 如果配置了降噪最后应用降噪
Args:
ctx: 渲染上下文
@@ -93,6 +97,7 @@ def mix_audio(
video_duration: 视频总时长用于截断音频
bgm_path: BGM 音频本地路径 None 时不混入 BGM
bgm_config: BGM 配置字典volume/fade_in/fade_out/sidechain
audio_tracks_config: 多轨道音频配置tracks/master_volume
Returns:
混音后的音频文件路径无音频时返回 None
@@ -156,10 +161,21 @@ def mix_audio(
try:
# 这里 main_audio 就是 output_path,先有主音频再混 BGM
final_path = mix_bgm_with_main(ctx, output_path, bgm_cfg, video_duration)
return _apply_noise_reduction_if_needed(ctx, final_path)
output_path = final_path
except Exception:
logger.exception("[bgm] BGM 混音失败,回退到无 BGM 音频: plan_id=%s", ctx.plan_id)
return _apply_noise_reduction_if_needed(ctx, output_path)
# ── 多轨道混音(配音/音效等) ──
if audio_tracks_config and audio_tracks_config.get("enabled", False):
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
try:
tracks_config = audio_tracks_config.get("tracks_config") or audio_tracks_config
multi_output = mix_audio_tracks_from_config(ctx, output_path, tracks_config, video_duration)
if multi_output and multi_output != output_path:
output_path = multi_output
except Exception:
logger.exception("[multi-track] 多轨道混音失败,回退: plan_id=%s", ctx.plan_id)
return _apply_noise_reduction_if_needed(ctx, output_path)
@@ -225,53 +241,118 @@ def concat_main_audio(
clip = clips[0]
effective_duration = clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
# 最终时长:取 clip 有效时长和视频总时长的较小值
# (视频总时长由主图层决定,但单 clip 场景下两者应该一致,仍做保护)
final_duration = effective_duration
speed = getattr(clip, "playback_speed", 1.0) or 1.0
if not isinstance(speed, (int, float)) or speed <= 0:
speed = 1.0
# 调速后时长
adjusted_duration = effective_duration / speed if abs(speed - 1.0) >= 1e-6 else effective_duration
# 最终时长:取调速后时长和视频总时长的较小值
final_duration = adjusted_duration
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
# 音频倒放
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
af_filters = []
if reverse_config.enabled and reverse_config.reverse_audio:
reverse_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
if reverse_filter:
af_filters.append(reverse_filter)
has_reverse = reverse_config.enabled and reverse_config.reverse_audio
has_speed = abs(speed - 1.0) >= 1e-6
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
]
if trim_start > 0:
command.extend(["-ss", f"{trim_start:.3f}"])
if af_filters:
command.extend(["-af", ",".join(af_filters)])
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
if not has_speed and not has_reverse:
# 无调速无倒放:简单命令行,-ss 裁剪更高效
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
]
if trim_start > 0:
command.extend(["-ss", f"{trim_start:.3f}"])
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
else:
# 有调速或倒放:用 filter_complex
speed_engine = SpeedEngine()
audio_filters = []
if effective_duration > 0:
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频调速
if has_speed:
from video_processing.speed_engine import SpeedConfig
config = SpeedConfig(speed=float(speed))
config.clamp()
atempo_filter = speed_engine.build_audio_filter(config)
if atempo_filter:
audio_filters.append(atempo_filter)
# 音频倒放
if has_reverse:
reverse_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
if reverse_filter:
audio_filters.append(reverse_filter)
filter_parts: list[str] = [f"[0:a]{','.join(audio_filters)}[outa]"]
if video_duration > 0 and final_duration < adjusted_duration:
filter_parts.append(f"[outa]atrim=0:{final_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "outa"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
run_ffmpeg(command)
return
# 多 clip,用 filter_complex concat
input_args: list[str] = []
filter_parts: list[str] = []
speed_engine = SpeedEngine()
for i, clip in enumerate(clips):
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
speed = getattr(clip, "playback_speed", 1.0) or 1.0
if not isinstance(speed, (int, float)) or speed <= 0:
speed = 1.0
audio_filters: list[str] = []
if effective_duration > 0:
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频调速 — atempo 多级串联
if abs(speed - 1.0) >= 1e-6:
from video_processing.speed_engine import SpeedConfig
config = SpeedConfig(speed=float(speed))
config.clamp()
atempo_filter = speed_engine.build_audio_filter(config)
if atempo_filter:
audio_filters.append(atempo_filter)
else:
audio_filters.append("asetpts=PTS-STARTPTS")
+167
View File
@@ -0,0 +1,167 @@
"""视频调速引擎 — 基于 FFmpeg setpts + atempo 的速度调整能力。
支持
- 0.25x ~ 4x 变速范围
- 视频调速setpts
- 音频调速atempo多级串联处理超范围值
- 音调修正pitch_correct默认开启
- 边界自动钳制不阻断渲染
"""
from dataclasses import dataclass
from typing import Optional
# ─── 常量 ───────────────────────────────────────────────
MIN_SPEED = 0.25
MAX_SPEED = 4.0
DEFAULT_SPEED = 1.0
# atempo 单级有效范围
_ATEMPO_MIN = 0.5
_ATEMPO_MAX = 2.0
@dataclass
class SpeedConfig:
"""调速配置。
Attributes:
speed: 播放速度0.25~4.01.0 为原速
pitch_correct: 是否保持音调默认 True atempo 时间拉伸算法
"""
speed: float = DEFAULT_SPEED
pitch_correct: bool = True
@classmethod
def parse(cls, data: Optional[dict]) -> "SpeedConfig":
"""从 dict 解析配置,无效值回退到默认。"""
if not data or not isinstance(data, dict):
return cls()
speed = data.get("speed", DEFAULT_SPEED)
if not isinstance(speed, (int, float)):
speed = DEFAULT_SPEED
pitch_correct = data.get("pitch_correct", True)
if not isinstance(pitch_correct, bool):
pitch_correct = True
config = cls(speed=float(speed), pitch_correct=pitch_correct)
config.clamp()
return config
def clamp(self) -> None:
"""将速度钳制到合法范围。"""
if self.speed <= 0:
self.speed = DEFAULT_SPEED
elif self.speed < MIN_SPEED:
self.speed = MIN_SPEED
elif self.speed > MAX_SPEED:
self.speed = MAX_SPEED
@property
def is_original(self) -> bool:
"""是否原速(无需调速)。"""
return abs(self.speed - 1.0) < 1e-6
class SpeedEngine:
"""调速引擎 — 生成 FFmpeg 调速滤镜链。
用法
engine = SpeedEngine()
video_filter = engine.build_video_filter(config)
audio_filter = engine.build_audio_filter(config)
new_duration = engine.adjust_duration(duration, config)
"""
def build_video_filter(self, config: SpeedConfig) -> str:
"""生成视频调速滤镜字符串。
返回 setpts 滤镜表达式原速时返回空字符串
"""
if config.is_original:
return ""
# setpts=PTS/speed — speed>1 加速,speed<1 减速
return f"setpts=PTS/{config.speed:.4f}"
def build_audio_filter(self, config: SpeedConfig) -> str:
"""生成音频调速滤镜字符串。
atempo 单级范围 0.5~2.0超出范围时自动多级串联
- 0.25x atempo=0.5,atempo=0.5
- 4x atempo=2.0,atempo=2.0
- 0.3x atempo=0.5,atempo=0.6
- 3x atempo=2.0,atempo=1.5
原速时返回空字符串
"""
if config.is_original:
return ""
speed = config.speed
stages: list[float] = self._split_atempo_stages(speed)
return ",".join(f"atempo={s:.4f}" for s in stages)
@staticmethod
def _split_atempo_stages(speed: float) -> list[float]:
"""将速度拆分为多级 atempo 串联,每级都在 [0.5, 2.0] 范围内。"""
if _ATEMPO_MIN <= speed <= _ATEMPO_MAX:
return [speed]
stages: list[float] = []
remaining = speed
# 加速场景(speed > 2.0
if speed > _ATEMPO_MAX:
while remaining > _ATEMPO_MAX:
stages.append(_ATEMPO_MAX)
remaining /= _ATEMPO_MAX
stages.append(remaining)
# 减速场景(speed < 0.5
else:
while remaining < _ATEMPO_MIN:
stages.append(_ATEMPO_MIN)
remaining /= _ATEMPO_MIN
stages.append(remaining)
return stages
def adjust_duration(self, original_duration: float, config: SpeedConfig) -> float:
"""计算调速后的时长。
加速 时长变短减速 时长变长
"""
if config.is_original or original_duration <= 0:
return original_duration
return original_duration / config.speed
def build_clip_speed_filter(
self,
speed: float,
pitch_correct: bool = True,
) -> tuple[str, str, SpeedConfig]:
"""便捷方法:从单一 speed 值生成视频+音频滤镜。
返回 (video_filter, audio_filter, config)
"""
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
config.clamp()
return (
self.build_video_filter(config),
self.build_audio_filter(config),
config,
)
@staticmethod
def resolve_clip_speed(
clip_config: dict,
global_speed: float = DEFAULT_SPEED,
) -> float:
"""从 clip config 中解析 playback_speed0 或缺失则使用全局速度。"""
speed = clip_config.get("playback_speed", 0) if clip_config else 0
if not isinstance(speed, (int, float)) or speed <= 0:
return global_speed
return float(speed)
+44 -6
View File
@@ -392,10 +392,48 @@ class StickerEngine:
)
parsed_stickers.append((z, config))
else:
# 图片贴纸
image_path = s.get("image_path", "") or s.get("image_url", "")
if not image_path or not Path(image_path).exists():
logger.warning("贴纸素材不存在,跳过: %s", image_path)
# 图片贴纸 — 安全校验:区分本地路径和URL
image_path = s.get("image_path", "")
image_url = s.get("image_url", "")
safe_image_path: Path | None = None
if image_path:
# 本地路径:路径遍历防护
from video_processing.path_security import is_in_allowed_dirs
try:
p = Path(image_path)
if not p.exists():
logger.warning("贴纸素材不存在,跳过: %s", image_path[:80])
continue
if not is_in_allowed_dirs(p):
logger.warning("贴纸路径不在允许目录内,拒绝: %s", image_path[:80])
continue
safe_image_path = p.resolve()
except Exception as e:
logger.warning("贴纸路径校验失败,跳过: %s error=%s", image_path[:80], e)
continue
elif image_url:
# URL:SSRF 安全校验(暂不自动下载,仅校验安全性)
from video_processing.url_security import (
UrlSecurityError,
validate_url_safety,
)
try:
validate_url_safety(image_url, purpose="sticker_image")
except UrlSecurityError as e:
logger.warning("贴纸URL安全校验失败,跳过: %s error=%s", image_url[:80], e)
continue
# URL 类型暂不支持自动下载,跳过
logger.info("贴纸URL类型暂不支持自动下载,跳过: %s", image_url[:80])
continue
else:
logger.warning("贴纸缺少 image_path 和 image_url,跳过")
continue
if safe_image_path is None:
continue
config = ImageStickerConfig(
@@ -414,11 +452,11 @@ class StickerEngine:
fade_in=float(s.get("fade_in", 0)),
fade_out=float(s.get("fade_out", 0)),
z_index=z,
image_url=str(s.get("image_url", "")),
image_url=image_url,
)
parsed_stickers.append((z, config))
image_stickers.append(config)
image_paths.append(image_path)
image_paths.append(str(safe_image_path))
except Exception as e:
logger.warning("贴纸配置解析失败,跳过: %s", e)
+687
View File
@@ -0,0 +1,687 @@
"""字幕渲染引擎 — 统一管理字幕样式配置与视频烧录.
与现有模块的关系
- render_subtitles.py生成静态整段标题/字幕的 ASS 文件
- subtitle_generator.py ASR 时间轴生成 ASS 文件
- 本模块统一的字幕样式配置 + 烧录滤镜生成 + 多源字幕合并
支持的字幕来源
1. 静态标题/字幕title_config / subtitle_config
2. ASR 自动字幕asr_subtitle_timeline
3. 手动字幕manual_subtitles 时间轴
支持的样式配置
- 字体字号颜色
- 描边颜色宽度
- 阴影偏移模糊颜色
- 背景框颜色透明度圆角边距
- 位置9宫格 + 自定义坐标
- 对齐方式
- 动画淡入淡出滑入滑出打字机
- 多行/换行规则
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
from video_processing.render_subtitles import generate_ass_subtitles
from video_processing.subtitle_generator import generate_ass_from_timeline
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"}
# 9宫格位置映射(ASS alignment 编号)
POSITION_ALIGNMENT = {
"top_left": 7,
"top_center": 8,
"top_right": 9,
"middle_left": 4,
"center": 5,
"middle_right": 6,
"bottom_left": 1,
"bottom_center": 2,
"bottom_right": 3,
}
# 位置简称兼容
POSITION_ALIASES = {
"top": "top_center",
"bottom": "bottom_center",
"middle": "center",
"left": "middle_left",
"right": "middle_right",
}
DEFAULT_FONT = "思源黑体"
DEFAULT_FONT_SIZE = 24
DEFAULT_COLOR = "#FFFFFF"
DEFAULT_STROKE_COLOR = "#000000"
DEFAULT_STROKE_WIDTH = 1.5
DEFAULT_POSITION = "bottom_center"
DEFAULT_MAX_CHARS_PER_LINE = 20
# ── 字幕样式配置 ────────────────────────────────────────────────────────────
@dataclass
class SubtitleStyle:
"""字幕样式配置."""
font_name: str = DEFAULT_FONT
font_size: int = DEFAULT_FONT_SIZE
font_color: str = DEFAULT_COLOR
bold: bool = False
italic: bool = False
# 描边
stroke_enabled: bool = True
stroke_color: str = DEFAULT_STROKE_COLOR
stroke_width: float = DEFAULT_STROKE_WIDTH
# 阴影
shadow_enabled: bool = False
shadow_color: str = "#000000"
shadow_offset_x: int = 2
shadow_offset_y: int = 2
shadow_blur: float = 0.0
# 背景框
background_enabled: bool = False
background_color: str = "#000000"
background_opacity: float = 0.5 # 0.0 ~ 1.0
background_padding: int = 8
background_radius: int = 4
# 位置
position: str = DEFAULT_POSITION # 9宫格位置名
margin_v: int = 60 # 垂直边距
margin_l: int = 40 # 左边距
margin_r: int = 40 # 右边距
# 多行
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE
line_spacing: int = 0 # 行间距
# 动画
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
animation_type: str = "none" # none/fade/slide/typewriter
@classmethod
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
"""从字典创建样式配置,带安全类型转换."""
if not config or not isinstance(config, dict):
return cls()
def safe_str(key: str, default: str) -> str:
val = config.get(key, default)
return str(val) if val is not None else default
def safe_int(key: str, default: int) -> int:
try:
return int(config.get(key, default))
except (TypeError, ValueError):
return default
def safe_float(key: str, default: float) -> float:
try:
return float(config.get(key, default))
except (TypeError, ValueError):
return default
def safe_bool(key: str, default: bool) -> bool:
return bool(config.get(key, default))
position = safe_str("position", DEFAULT_POSITION)
position = POSITION_ALIASES.get(position, position)
if position not in POSITION_ALIGNMENT:
position = DEFAULT_POSITION
return cls(
font_name=safe_str("font", DEFAULT_FONT),
font_size=safe_int("size", DEFAULT_FONT_SIZE),
font_color=safe_str("color", DEFAULT_COLOR),
bold=safe_bool("bold", False),
italic=safe_bool("italic", False),
stroke_enabled=safe_bool("stroke_enabled", True),
stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR),
stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH),
shadow_enabled=safe_bool("shadow_enabled", False),
shadow_color=safe_str("shadow_color", "#000000"),
shadow_offset_x=safe_int("shadow_offset_x", 2),
shadow_offset_y=safe_int("shadow_offset_y", 2),
shadow_blur=safe_float("shadow_blur", 0.0),
background_enabled=safe_bool("background_enabled", False),
background_color=safe_str("background_color", "#000000"),
background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))),
background_padding=safe_int("background_padding", 8),
background_radius=safe_int("background_radius", 4),
position=position,
margin_v=safe_int("margin_v", 60),
margin_l=safe_int("margin_l", 40),
margin_r=safe_int("margin_r", 40),
max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE),
line_spacing=safe_int("line_spacing", 0),
fade_in=max(0.0, safe_float("fade_in", 0.0)),
fade_out=max(0.0, safe_float("fade_out", 0.0)),
animation_type=safe_str("animation_type", "none"),
)
@property
def alignment(self) -> int:
"""获取 ASS alignment 编号."""
return POSITION_ALIGNMENT.get(self.position, 2)
@property
def ass_font_color(self) -> str:
"""ASS 格式颜色 &HAABBGGRR."""
return _hex_to_ass_color(self.font_color)
@property
def ass_stroke_color(self) -> str:
return _hex_to_ass_color(self.stroke_color)
@property
def ass_shadow_color(self) -> str:
return _hex_to_ass_color(self.shadow_color)
@property
def ass_background_color(self) -> str:
"""背景框颜色(ASS BackColour),带透明度."""
alpha_hex = _opacity_to_ass_alpha(self.background_opacity)
color_bgr = _hex_to_ass_bgr(self.background_color)
return f"&H{alpha_hex}{color_bgr}"
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _hex_to_ass_color(hex_color: str) -> str:
"""HEX → ASS 颜色 &HAABBGGRR(默认不透明)."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H00FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H00{b.upper()}{g.upper()}{r.upper()}"
def _hex_to_ass_bgr(hex_color: str) -> str:
"""HEX → ASS BGR 部分(不含 alpha."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"{b.upper()}{g.upper()}{r.upper()}"
def _opacity_to_ass_alpha(opacity: float) -> str:
"""不透明度 → ASS alpha(00=不透明,FF=完全透明)."""
alpha = 255 - int(opacity * 255)
return f"{alpha:02X}"
def _escape_ass_text(text: str) -> str:
"""转义 ASS 文本特殊字符."""
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
text = text.replace("{", "(").replace("}", ")")
return text
def _format_ass_time(seconds: float) -> str:
"""秒 → ASS 时间格式 H:MM:SS.cc."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def _wrap_text(text: str, max_chars: int) -> list[str]:
"""按字数换行,优先标点断开."""
if len(text) <= max_chars:
return [text]
lines: list[str] = []
remaining = text
while len(remaining) > max_chars:
break_point = max_chars
punctuations = ",。!?、;:,.;:!?"
for i in range(max_chars, max_chars // 2, -1):
if i < len(remaining) and remaining[i] in punctuations:
break_point = i + 1
break
lines.append(remaining[:break_point])
remaining = remaining[break_point:]
if remaining:
lines.append(remaining)
return lines
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
@dataclass
class SubtitleSegment:
"""单个字幕片段."""
start: float # 开始时间(秒)
end: float # 结束时间(秒)
text: str # 字幕文本
style_name: str = "Default" # 使用的样式名
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
class SubtitleRenderEngine:
"""字幕渲染引擎 — 统一管理多源字幕的 ASS 文件生成.
支持合并多个字幕来源到同一个 ASS 文件
- 标题顶部单独样式
- 字幕底部单独样式
- ASR 时间轴字幕
- 手动字幕
输出一个统一的 ASS 文件 FFmpeg subtitles filter 烧录
"""
def __init__(
self,
video_width: int = 1080,
video_height: int = 1920,
video_duration: float = 0.0,
):
self.video_width = video_width
self.video_height = video_height
self.video_duration = video_duration
self._styles: dict[str, SubtitleStyle] = {}
self._segments: list[SubtitleSegment] = []
self._style_counter = 0
# ── 样式管理 ──────────────────────────────────────────────────────
def add_style(self, name: str, style: SubtitleStyle) -> str:
"""注册一个样式,返回样式名."""
self._styles[name] = style
return name
def get_or_create_style(self, base_name: str, style: SubtitleStyle) -> str:
"""获取或创建样式(避免重复)."""
if base_name in self._styles:
return base_name
self._styles[base_name] = style
return base_name
# ── 字幕源添加 ────────────────────────────────────────────────────
def add_title(self, text: str, style: SubtitleStyle | None = None) -> None:
"""添加整段标题(显示整个视频时长)."""
if not text or not text.strip():
return
style = style or SubtitleStyle(
position="top_center",
font_size=48,
bold=True,
stroke_enabled=True,
stroke_width=2.0,
)
style_name = self.get_or_create_style("TitleStyle", style)
self._segments.append(
SubtitleSegment(
start=0.0,
end=self.video_duration if self.video_duration > 0 else 9999.0,
text=text.strip(),
style_name=style_name,
)
)
def add_subtitle_text(self, text: str, style: SubtitleStyle | None = None) -> None:
"""添加整段字幕(显示整个视频时长)."""
if not text or not text.strip():
return
style = style or SubtitleStyle()
style_name = self.get_or_create_style("SubtitleStyle", style)
self._segments.append(
SubtitleSegment(
start=0.0,
end=self.video_duration if self.video_duration > 0 else 9999.0,
text=text.strip(),
style_name=style_name,
)
)
def add_timeline_segments(
self,
segments: list[dict] | list[SubtitleSegment],
style: SubtitleStyle | None = None,
) -> None:
"""添加时间轴字幕片段(ASR 或手动字幕).
segments 可以是
- SubtitleSegment 列表
- dict 列表每个 dict start/end/text 字段
"""
if not segments:
return
style = style or SubtitleStyle()
style_name = self.get_or_create_style("Default", style)
for seg in segments:
if isinstance(seg, SubtitleSegment):
seg.style_name = style_name
self._segments.append(seg)
elif isinstance(seg, dict):
try:
start = float(seg.get("start", 0))
end = float(seg.get("end", 0))
text = str(seg.get("text", ""))
if end > start and text.strip():
self._segments.append(
SubtitleSegment(
start=start,
end=end,
text=text.strip(),
style_name=style_name,
)
)
except (TypeError, ValueError):
continue
def add_asr_timeline(self, timeline: Any, style: SubtitleStyle | None = None) -> None:
"""从 SubtitleTimeline 对象添加 ASR 字幕."""
if not timeline or not hasattr(timeline, "segments") or not timeline.segments:
return
style = style or SubtitleStyle()
style_name = self.get_or_create_style("ASRStyle", style)
for seg in timeline.segments:
if hasattr(seg, "start") and hasattr(seg, "end") and hasattr(seg, "text"):
if seg.end > seg.start and seg.text.strip():
self._segments.append(
SubtitleSegment(
start=seg.start,
end=seg.end,
text=seg.text.strip(),
style_name=style_name,
)
)
# ── ASS 文件生成 ──────────────────────────────────────────────────
def generate_ass(self, output_path: Path) -> Path:
"""生成 ASS 字幕文件.
Returns:
生成的文件路径如果没有字幕内容返回空文件
"""
if not self._segments:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("", encoding="utf-8")
return output_path
# 确保至少有 Default 样式
if "Default" not in self._styles:
self._styles["Default"] = SubtitleStyle()
# 生成样式行
style_lines = []
for name, style in self._styles.items():
style_lines.append(self._build_ass_style_line(name, style))
# 生成事件行(按时间排序)
self._segments.sort(key=lambda s: s.start)
event_lines = []
for seg in self._segments:
event_lines.append(self._build_ass_event_line(seg))
# 组装文件
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {self.video_width}
PlayResY: {self.video_height}
ScaledBorderAndShadow: yes
WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
{chr(10).join(style_lines)}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(event_lines)}
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(ass_content, encoding="utf-8")
return output_path
def _build_ass_style_line(self, name: str, style: SubtitleStyle) -> str:
"""构建一条 ASS Style 行."""
bold_val = -1 if style.bold else 0
italic_val = -1 if style.italic else 0
# BorderStyle: 1=outline+shadow, 3=opaque box(背景框)
if style.background_enabled:
border_style = 3
back_color = style.ass_background_color
else:
border_style = 1
back_color = style.ass_shadow_color if style.shadow_enabled else style.ass_font_color
outline_val = style.stroke_width if style.stroke_enabled else 0.0
shadow_val = style.shadow_offset_y if style.shadow_enabled else 0
return (
f"Style: {name},{style.font_name},{style.font_size},{style.ass_font_color},"
f"&H000000FF,{style.ass_stroke_color},{back_color},"
f"{bold_val},{italic_val},0,0,100,100,0,0,"
f"{border_style},{outline_val},{shadow_val},{style.alignment},"
f"{style.margin_l},{style.margin_r},{style.margin_v},1"
)
def _build_ass_event_line(self, seg: SubtitleSegment) -> str:
"""构建一条 ASS Dialogue 事件行."""
style = self._styles.get(seg.style_name, SubtitleStyle())
max_chars = style.max_chars_per_line
# 自动换行
lines = _wrap_text(seg.text, max_chars)
display_text = "\\N".join(lines)
# 动画效果(淡入淡出)
effect_tags = ""
if style.fade_in > 0 or style.fade_out > 0:
fade_in_ms = int(style.fade_in * 1000)
fade_out_ms = int(style.fade_out * 1000)
effect_tags = f"{{\\fad({fade_in_ms},{fade_out_ms})}}"
safe_text = _escape_ass_text(display_text)
start_time = _format_ass_time(max(0, seg.start))
end_time = _format_ass_time(max(seg.start + 0.1, seg.end))
return f"Dialogue: 0,{start_time},{end_time},{seg.style_name},,0,0,0,," f"{effect_tags}{safe_text}"
@property
def has_subtitles(self) -> bool:
"""是否有字幕内容."""
return len(self._segments) > 0
# ── 便捷函数:从 plan.config 快速生成 ASS ────────────────────────────────────
def build_subtitles_from_plan(
output_path: Path,
plan_config: dict,
*,
video_width: int,
video_height: int,
video_duration: float,
asr_timeline: Any = None,
) -> Path | None:
"""从 plan.config 构建字幕 ASS 文件.
支持的配置项
- title_config: 标题配置 text/style
- subtitle_config: 字幕配置 text/style
- asr_subtitles: ASR 字幕开关 + 样式
- manual_subtitles: 手动字幕片段列表
Returns:
生成的 ASS 文件路径如果没有任何字幕返回 None
"""
engine = SubtitleRenderEngine(
video_width=video_width,
video_height=video_height,
video_duration=video_duration,
)
has_any = False
# 1. 标题
title_cfg = plan_config.get("title_config") or {}
if isinstance(title_cfg, dict):
title_text = str(title_cfg.get("text", ""))
title_enabled = title_cfg.get("enabled", True)
if title_enabled and title_text.strip():
style_dict = title_cfg.get("style") or {}
style = SubtitleStyle.from_dict(style_dict)
# 标题默认样式:顶部、大字号、粗体
if style.position == DEFAULT_POSITION and style.font_size == DEFAULT_FONT_SIZE:
style.position = "top_center"
style.font_size = 48
style.bold = True
engine.add_title(title_text, style)
has_any = True
# 2. 静态字幕
sub_cfg = plan_config.get("subtitle_config") or {}
if isinstance(sub_cfg, dict):
sub_text = str(sub_cfg.get("text", ""))
sub_enabled = sub_cfg.get("enabled", True)
if sub_enabled and sub_text.strip():
style_dict = sub_cfg.get("style") or {}
style = SubtitleStyle.from_dict(style_dict)
engine.add_subtitle_text(sub_text, style)
has_any = True
# 3. ASR 自动字幕
asr_cfg = plan_config.get("asr_subtitles") or {}
if isinstance(asr_cfg, dict) and asr_cfg.get("enabled", False):
if asr_timeline is not None:
style_dict = asr_cfg.get("style") or {}
style = SubtitleStyle.from_dict(style_dict)
engine.add_asr_timeline(asr_timeline, style)
has_any = has_any or engine.has_subtitles
# 4. 手动字幕
manual_segs = plan_config.get("manual_subtitles") or []
if isinstance(manual_segs, list) and manual_segs:
style_dict = (plan_config.get("manual_subtitle_style") or {}) or {}
style = SubtitleStyle.from_dict(style_dict)
engine.add_timeline_segments(manual_segs, style)
has_any = has_any or engine.has_subtitles
if not has_any:
return None
return engine.generate_ass(output_path)
# ── FFmpeg 烧录滤镜生成 ───────────────────────────────────────────────────────
def build_subtitle_filter(
ass_path: Path | str,
*,
video_input_label: str = "0:v",
output_label: str = "subtitled",
work_dir: Path | str | None = None,
) -> str:
"""生成 FFmpeg subtitles 滤镜字符串.
Args:
ass_path: ASS 字幕文件路径
video_input_label: 视频输入标签 "0:v" "[v_out]"
output_label: 输出标签
work_dir: 工作目录必填用于路径安全校验防止路径遍历绕过
Returns:
filter_complex 片段 "[0:v]subtitles=xxx.ass[subtitled]"
Raises:
PathSecurityError: 字幕路径不安全或 work_dir 未提供
"""
# ── 安全校验:字幕文件路径白名单 ──
ass_path_str = str(ass_path)
if work_dir is None or not str(work_dir).strip():
raise PathSecurityError("work_dir 必须提供,不能为 None 或空")
_validate_subtitle_path(ass_path_str, Path(work_dir))
# FFmpeg subtitles filter 的路径需要转义:
# - Windows 路径的 \ → /
# - 冒号 : → \:
# - 单引号 ' → '\''
safe_path = ass_path_str.replace("\\", "/").replace(":", "\\:").replace("'", "'\\''")
return f"{video_input_label}subtitles='{safe_path}'[{output_label}]"
def _validate_subtitle_path(subtitle_path: str, work_dir: Path) -> None:
"""校验字幕文件路径安全性.
规则
- 必须是本地路径不支持远程URL字幕
- local:// schema 必须在 work_dir
- 相对路径 必须在 work_dir
- 绝对路径 必须在允许目录白名单内
- 扩展名必须是字幕格式
Raises:
PathSecurityError: 路径不安全
"""
if not subtitle_path or not isinstance(subtitle_path, str):
raise PathSecurityError("字幕路径不能为空")
# 不允许远程URL字幕(subtitles滤镜不支持远程加载,且有SSRF风险)
if subtitle_path.startswith(("http://", "https://", "oss://")):
raise PathSecurityError("不允许使用远程URL字幕文件")
is_abs = subtitle_path.startswith("/") and not subtitle_path.startswith("local://")
resolved_path = safe_resolve_path(
subtitle_path,
work_dir,
allow_outside=is_abs,
allowed_extensions=ALLOWED_SUBTITLE_EXTENSIONS,
)
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
if is_abs:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
+2 -2
View File
@@ -186,8 +186,8 @@ class TrimEngine:
FFmpeg filter 字符串 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]"
"""
if trim.is_noop:
# 不裁剪,直接直通
return f"{input_label}copy{output_label}" if False else f"{input_label}setpts=PTS-STARTPTS{output_label}"
# 不裁剪,直接直通(仅重置时间戳)
return f"{input_label}setpts=PTS-STARTPTS{output_label}"
parts: list[str] = []
@@ -28,7 +28,6 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.chroma_key_engine import apply_chroma_key_if_needed
from video_processing.color_grade_engine import ColorGradeConfig, ColorGradeEngine
from video_processing.ffmpeg_utils import (
DEFAULT_FPS,
@@ -45,7 +44,8 @@ from video_processing.pip_engine import PiPConfig, PiPEngine, PiPLayerConfig
from video_processing.render_audio import RenderContext, merge_audio_video, mix_audio
from video_processing.render_subtitles import generate_ass_subtitles
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
from video_processing.sticker_engine import StickerEngine, parse_stickers_from_config
from video_processing.speed_engine import SpeedConfig, SpeedEngine
from video_processing.sticker_engine import StickerEngine
from video_processing.subtitle_generator import generate_ass_from_timeline
from video_processing.transition_engine import TransitionEngine
from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_from_clip_config
@@ -73,6 +73,7 @@ class ResolvedClip:
duration: float = 0.0 # 0 表示使用素材完整时长
transition_effect: str = "cut"
transition_duration: float = 0.0 # 0 表示使用全局默认值
playback_speed: float = 1.0 # 0 或 1.0 表示原速
config: dict[str, Any] = field(default_factory=dict)
# 运行时填充
@@ -186,6 +187,7 @@ class UnifiedRenderService:
self.asr_service = asr_service
self.bgm_path = bgm_path
self._transition_engine = TransitionEngine(default_duration=transition_duration)
self._speed_engine = SpeedEngine()
def render(self) -> RenderResult:
"""执行渲染,返回 RenderResult.
@@ -340,6 +342,7 @@ class UnifiedRenderService:
else:
config = self.plan.config or {}
bgm_config = config.get("bgm", {}) or {}
audio_tracks_config = config.get("audio_tracks") or {}
noise_reduction_config = config.get("audio_noise_reduction")
ctx = RenderContext(
work_dir=self.work_dir,
@@ -352,6 +355,7 @@ class UnifiedRenderService:
video_duration,
bgm_path=self.bgm_path,
bgm_config=bgm_config,
audio_tracks_config=audio_tracks_config,
)
t_audio_end = time.time()
audio_mix_ms = int((t_audio_end - t_audio_start) * 1000)
@@ -493,7 +497,7 @@ class UnifiedRenderService:
if not main_layer or not main_layer.clips:
return 0.0
total = sum(UnifiedRenderService._clip_effective_duration(c) for c in main_layer.clips)
total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in main_layer.clips)
# 减去转场重叠时间(粗略估算)
n_clips = len(main_layer.clips)
@@ -637,10 +641,8 @@ class UnifiedRenderService:
def _extract_audio(self, video_path: Path, output_path: Path) -> None:
"""从视频中提取音频为16kHz单声道wav(ASR友好格式)。"""
import subprocess
cmd = [
"ffmpeg",
FFMPEG_BIN,
"-y",
"-i",
str(video_path),
@@ -654,15 +656,10 @@ class UnifiedRenderService:
str(output_path),
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
raise RuntimeError(f"音频提取失败: {result.stderr[:200]}")
try:
run_ffmpeg(cmd, timeout=120)
except Exception as e:
raise RuntimeError(f"音频提取失败: {str(e)[:200]}") from e
def _maybe_add_voiceover_layer(
self,
@@ -1197,6 +1194,7 @@ class UnifiedRenderService:
duration=final_duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
config=clip_config,
actual_duration=actual_duration,
trim_config=effective_trim,
@@ -1294,6 +1292,11 @@ class UnifiedRenderService:
filters.append(f"trim=duration={effective_duration:.3f}")
filters.append("setpts=PTS-STARTPTS")
# 调速 — 基于 setpts 改变播放速度
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{speed:.4f}")
# 倒放滤镜(在 trim 之后、scale 之前应用)
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
if reverse_config.enabled and reverse_config.reverse_video:
@@ -1351,8 +1354,8 @@ class UnifiedRenderService:
for layer in layers:
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
# 使用 trim 后的有效时长,与 Step 1 的 trim=duration 保持一致
layer_durations = [UnifiedRenderService._clip_effective_duration(all_clips[i]) for i in layer_clip_indices]
# 使用调速后的实际时长,与 Step 1 的调速处理保持一致
layer_durations = [UnifiedRenderService._clip_adjusted_duration(all_clips[i]) for i in layer_clip_indices]
layer_transitions = [all_clips[i].transition_effect for i in layer_clip_indices]
layer_transition_durations = [all_clips[i].transition_duration for i in layer_clip_indices]
@@ -1597,7 +1600,7 @@ class UnifiedRenderService:
@staticmethod
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长."""
"""计算 clip 的有效时长(原速 trim 后时长)."""
if clip.duration > 0:
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
@@ -1694,3 +1697,20 @@ class UnifiedRenderService:
)
return new_filter, new_input_args
@staticmethod
def _clip_speed(clip: ResolvedClip) -> float:
"""获取 clip 的播放速度,无效值回退到 1.0."""
speed = getattr(clip, "playback_speed", 1.0)
if not isinstance(speed, (int, float)) or speed <= 0:
return 1.0
return float(speed)
@staticmethod
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
"""计算调速后的 clip 实际时长(用于拼接计算)."""
base = UnifiedRenderService._clip_effective_duration(clip)
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) < 1e-6:
return base
return base / speed
@@ -0,0 +1,21 @@
"""URL 安全校验工具 — SSRF 防护(向后兼容层).
本模块为向后兼容而保留实际实现已迁移至 packages.shared.url_security
所有符号均从该模块重新导出请新代码直接 import packages.shared.url_security
"""
from packages.shared.url_security import ( # noqa: F401
ALLOWED_AUDIO_MIME_TYPES,
ALLOWED_IMAGE_MIME_TYPES,
ALLOWED_PORTS,
ALLOWED_SCHEMES,
ALLOWED_VIDEO_MIME_TYPES,
DEFAULT_MAX_DOWNLOAD_SIZE,
MAX_URL_LENGTH,
TRUSTED_DOMAINS,
UrlSecurityError,
is_url_safe,
safe_download_bytes,
safe_download_file,
validate_url_safety,
)
+43 -43
View File
@@ -130,6 +130,8 @@ class AssetAnalyzer:
info = VideoInfo()
try:
from video_processing.ffmpeg_utils import run_ffprobe
cmd = [
"ffprobe",
"-v",
@@ -140,38 +142,31 @@ class AssetAnalyzer:
"-show_streams",
self.video_path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
stdout, _ = run_ffprobe(cmd, timeout=30)
data = json.loads(stdout)
streams = data.get("streams", [])
format_info = data.get("format", {})
if result.returncode == 0:
data = json.loads(result.stdout)
streams = data.get("streams", [])
format_info = data.get("format", {})
for stream in streams:
if stream.get("codec_type") == "video":
info.width = int(stream.get("width", 0))
info.height = int(stream.get("height", 0))
info.codec = stream.get("codec_name", "")
for stream in streams:
if stream.get("codec_type") == "video":
info.width = int(stream.get("width", 0))
info.height = int(stream.get("height", 0))
info.codec = stream.get("codec_name", "")
# 解析帧率
fps_str = stream.get("r_frame_rate", "0/1")
if "/" in fps_str:
num, denom = fps_str.split("/")
info.fps = float(num) / float(denom) if float(denom) != 0 else 0.0
else:
info.fps = float(fps_str)
# 解析帧率
fps_str = stream.get("r_frame_rate", "0/1")
if "/" in fps_str:
num, denom = fps_str.split("/")
info.fps = float(num) / float(denom) if float(denom) != 0 else 0.0
else:
info.fps = float(fps_str)
elif stream.get("codec_type") == "audio":
info.has_audio = True
elif stream.get("codec_type") == "audio":
info.has_audio = True
info.duration = float(format_info.get("duration", 0))
info.bitrate = int(format_info.get("bit_rate", 0))
info.file_size = int(format_info.get("size", 0))
info.duration = float(format_info.get("duration", 0))
info.bitrate = int(format_info.get("bit_rate", 0))
info.file_size = int(format_info.get("size", 0))
except Exception as e:
logger.warning(f"Failed to get video info: {e}")
@@ -179,7 +174,7 @@ class AssetAnalyzer:
self._video_info = info
return info
def extract_frames(self, count: int = 10, max_frames: int = 30) -> list[np.ndarray]:
def extract_frames(self, count: int = 10) -> list[np.ndarray]:
"""
从视频中均匀抽取帧
@@ -224,14 +219,14 @@ class AssetAnalyzer:
output_path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10,
)
from video_processing.ffmpeg_utils import run_ffmpeg
if result.returncode == 0 and os.path.exists(output_path):
try:
run_ffmpeg(cmd, timeout=10)
except Exception:
continue
if os.path.exists(output_path):
# 读取帧并转换为 numpy 数组
img = self._load_image_as_array(output_path)
if img is not None:
@@ -397,14 +392,19 @@ class AssetAnalyzer:
audio_path,
]
result_audio = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
from video_processing.ffmpeg_utils import run_ffmpeg
if result_audio.returncode == 0 and os.path.exists(audio_path):
try:
run_ffmpeg(cmd, timeout=30)
except Exception:
# 音频提取失败,返回默认分析结果
return AudioAnalysis(
has_speech=False,
speech_ratio=0.0,
avg_volume=0.0,
)
if os.path.exists(audio_path):
# 读取音频数据
import struct
+12 -3
View File
@@ -106,7 +106,16 @@ def _download_video_to_file(url: str, dest_path: str) -> None:
except Exception:
pass
# 回退到 HTTP 下载
import urllib.request
# 回退到 HTTP 下载(含 SSRF 防护 + 大小限制 + 类型校验)
from video_processing.url_security import (
ALLOWED_VIDEO_MIME_TYPES,
safe_download_file,
)
urllib.request.urlretrieve(url, dest_path) # nosec B310
safe_download_file(
url,
dest_path,
purpose="batch_video_download",
allowed_mime_types=ALLOWED_VIDEO_MIME_TYPES | {"application/octet-stream"},
timeout=300.0,
)
+6 -11
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import os
import subprocess
import tempfile
from pathlib import Path
@@ -115,16 +114,12 @@ def _compose_with_legacy_engine(task, job_service, job, plan_id: str, db) -> dic
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
try:
subprocess.run(
compose_cmd.command,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3600,
)
except subprocess.CalledProcessError as e:
job_service.fail_job(job_id, f"FFmpeg 执行失败: {e.stderr[:500]}")
from video_processing.ffmpeg_utils import run_ffmpeg
run_ffmpeg(compose_cmd.command, timeout=3600)
except Exception as e:
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
job_service.fail_job(job_id, error_msg)
raise
# 上传结果
@@ -257,7 +257,6 @@ def _render_with_legacy(
) -> dict:
"""旧引擎路径(VideoComposeService + FFmpeg filter_complex)。"""
import os
import subprocess
from apps.api.app.services.video_compose_service import VideoComposeService
@@ -278,16 +277,11 @@ def _render_with_legacy(
logger.info("执行 FFmpeg (legacy): plan_id=%s", plan_id)
try:
subprocess.run(
compose_cmd.command,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3600,
)
except subprocess.CalledProcessError as e:
error_msg = f"FFmpeg 执行失败: {e.stderr[:500]}"
from video_processing.ffmpeg_utils import run_ffmpeg
run_ffmpeg(compose_cmd.command, timeout=3600)
except Exception as e:
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
logger.error("FFmpeg 执行失败(legacy): %s%s", plan_id, error_msg)
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
return {"status": "error", "message": error_msg}
+93 -13
View File
@@ -364,10 +364,19 @@ def _prepare_bgm_track(
try:
parsed = urlparse(audio_url)
if parsed.scheme in ("http", "https"):
import urllib.request
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
urllib.request.urlretrieve(audio_url, bgm_file) # nosec B310
safe_download_file(
audio_url,
str(bgm_file),
purpose="bgm_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
@@ -401,10 +410,19 @@ def _prepare_bgm_track(
preset = get_preset_bgm(preset_id)
if preset and preset.audio_url:
import urllib.request
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
urllib.request.urlretrieve(preset.audio_url, bgm_file) # nosec B310
safe_download_file(
preset.audio_url,
str(bgm_file),
purpose="bgm_preset_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
@@ -415,29 +433,93 @@ def _prepare_bgm_track(
return None
def _verify_url_accessible(url: str, timeout: float = 10.0, retries: int = 2) -> bool:
def _verify_url_accessible(
url: str,
timeout: float = 10.0,
retries: int = 2,
max_redirects: int = 5,
) -> bool:
"""HEAD 请求校验 URL 可访问(含重试,防止 OSS 抖动误报)。
安全增强
- 请求前先做 SSRF 安全校验内网IP/回环地址/链路本地地址等
- scheme 仅允许 http/https
- 端口仅允许 80/443
- 手动跟随重定向每一跳 URL 都做 SSRF 校验避免重定向到内网地址绕过
Args:
url: 待校验的 URL
timeout: 单次请求超时时间
retries: 最大重试次数默认 2 首次失败后间隔 1s 重试
max_redirects: 最大重定向次数默认 5
Returns:
True 表示 URL 可访问HTTP 2xx/3xxFalse 表示所有尝试均失败
True 表示 URL 可访问HTTP 2xx/3xxFalse 表示所有尝试均失败或安全校验不通过
"""
import time
import urllib.request
from urllib.parse import urljoin
from video_processing.url_security import UrlSecurityError, validate_url_safety
# P0-1 SSRF 防护:请求前先校验 URL 安全性
try:
validate_url_safety(url, purpose="url_verify")
except UrlSecurityError as e:
logger.warning("URL 安全校验失败,拒绝访问: url=%s error=%s", url[:80], e)
return False
last_error: Exception | None = None
for attempt in range(1 + retries):
try:
req = urllib.request.Request(url, method="HEAD")
def _do_verify(current_url: str) -> bool:
"""单次校验:手动跟随重定向,每跳都做 SSRF 检查."""
redirect_count = 0
url_being_checked = current_url
# 禁止自动重定向的 handler,手动控制每一跳
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: N802
return None
opener = urllib.request.build_opener(NoRedirect())
while redirect_count <= max_redirects:
# 每一跳都做 SSRF 安全校验
try:
safe_url = validate_url_safety(url_being_checked, purpose="url_verify")
except UrlSecurityError as e:
logger.warning(
"URL校验跳转地址不安全: redirect=%d url=%s error=%s",
redirect_count,
url_being_checked,
e,
)
raise
req = urllib.request.Request(safe_url, method="HEAD")
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
with opener.open(req, timeout=timeout) as resp: # noqa: S310
if 200 <= resp.status < 300:
return True
if resp.status in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if not location:
raise Exception(f"HTTP {resp.status} 但无 Location 头")
# 相对路径转绝对
url_being_checked = urljoin(safe_url, location)
redirect_count += 1
continue
if resp.status < 400:
return True
last_error = Exception(f"HTTP {resp.status}")
raise Exception(f"HTTP {resp.status}")
raise Exception(f"重定向次数超过上限 ({max_redirects})")
for attempt in range(1 + retries):
try:
if _do_verify(url):
return True
except Exception as e:
last_error = e
@@ -461,7 +543,6 @@ def _download_library_assets(
asset_library_id: str = "",
project_id: str = "",
asset_ids: list[str] | None = None,
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
strict: bool = True,
task_id: str = "",
gen_task=None,
@@ -480,7 +561,6 @@ def _download_library_assets(
asset_library_id: 素材库 ID可选 project_id 二选一
project_id: 项目 ID可选 asset_library_id 二选一
asset_ids: 指定素材 ID 列表为空则下载全部 ready 视频素材
video_extensions: 支持的视频扩展名保留兼容当前按 file_type 过滤
strict: 严格模式默认 True
True 任何素材下载失败立即抛 RuntimeError
False 跳过失败素材返回成功列表调用方可通过日志感知失败
+8 -8
View File
@@ -45,6 +45,8 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
try:
if media_type == "video":
# 使用 ffprobe 提取视频元数据
from video_processing.ffmpeg_utils import run_ffprobe
cmd = [
"ffprobe",
"-v",
@@ -55,16 +57,11 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
"-show_streams",
file_url,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
try:
stdout, _ = run_ffprobe(cmd, timeout=30)
import json as json_lib
probe_data = json_lib.loads(result.stdout)
probe_data = json_lib.loads(stdout)
# 提取视频流信息
for stream in probe_data.get("streams", []):
@@ -83,6 +80,9 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
metadata["size_bytes"] = int(format_info.get("size", 0))
metadata["bitrate"] = int(format_info.get("bit_rate", 0))
except Exception as e:
logger.warning("视频元数据提取失败: %s", e)
elif media_type == "image":
# 使用 Pillow 提取图片元数据
try:
@@ -19,14 +19,12 @@ class VoiceExtractor:
"""Extract voice tracks and background music from videos using FFmpeg."""
@staticmethod
def _run_ffmpeg(cmd: list[str]) -> subprocess.CompletedProcess:
"""Run FFmpeg command and return result."""
logger.info(f"Running FFmpeg: {chr(39).join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.error(f"FFmpeg error: {result.stderr}")
raise RuntimeError(f"FFmpeg failed: {result.stderr}")
return result
def _run_ffmpeg(cmd: list[str]) -> None:
"""Run FFmpeg command using 统一 run_ffmpeg 工具."""
from video_processing.ffmpeg_utils import run_ffmpeg
logger.info("Running FFmpeg: %s", " ".join(cmd[:10]))
run_ffmpeg(cmd)
def extract_voice(
self,
+8
View File
@@ -866,6 +866,14 @@
"type": "FLOAT",
"unique": false
},
{
"index": false,
"name": "playback_speed",
"nullable": false,
"primary_key": false,
"type": "FLOAT",
"unique": false
},
{
"index": true,
"name": "status",
+87 -31
View File
@@ -1,48 +1,104 @@
# ============================================================
# Worker Dockerfile - 专门用于 Celery Worker
# 优化:依赖分层缓存,基础大包和业务依赖分开
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身)
# 优化
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
# ============================================================
# 基础镜像:Python 3.12 + ffmpeg
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# ==================== Builder 阶段 ====================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
# 构建参数:版本号(CI 传入 commit hash
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 安装编译工具(仅 builder 需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
python3-dev \
binutils \
wget \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# ---- 下载静态编译 ffmpeg ----
# 使用 johnvansickle.com 的静态编译版本(业界标准)
RUN cd /tmp \
&& wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
&& tar xf ffmpeg-release-amd64-static.tar.xz \
&& cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ffmpeg \
&& cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ffprobe \
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
&& rm -rf ffmpeg-*
# ---- 安装 Python 依赖 ----
WORKDIR /tmp
# 创建 venv
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 基础依赖
COPY requirements-base.txt /tmp/requirements-base.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements-base.txt \
&& rm /tmp/requirements-base.txt
# Worker 专属大包
COPY requirements-worker.txt /tmp/requirements-worker.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements-worker.txt \
&& rm /tmp/requirements-worker.txt
# 业务依赖
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
# ---- Python 依赖瘦身 ----
# 1. strip .so 文件的调试符号(节省约 80-100MB)
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
# 2. 清理测试文件(节省约 20MB)
RUN find /opt/venv -type d -name "tests" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -type d -name "test" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "test_*.py" -delete 2>/dev/null || true
# 3. 清理 .pyc 缓存和 __pycache__(节省约 10MB,运行时按需生成)
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
# 4. 清理 dist-info 中的文档
RUN find /opt/venv -name "*.dist-info" -type d -exec sh -c 'rm -f "$1"/DESCRIPTION.rst "$1"/INSTALLER "$1"/LICENSE* "$1"/WHEEL "$1"/entry_points.txt' _ {} \; 2>/dev/null || true
# ==================== Runtime 阶段 ====================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
# 构建参数:版本号
ARG APP_VERSION=dev
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 安装系统依赖
# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libsm6 \
libxext6 \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# 从 builder 复制 ffmpeg 静态二进制
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
# 从 builder 复制 Python 虚拟环境
COPY --from=builder /opt/venv /opt/venv
# 设置工作目录
WORKDIR /app
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
COPY requirements-base.txt /tmp/requirements-base.txt
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt \
&& rm /tmp/requirements-base.txt
# ---- 依赖分层:Worker 专属大包(视频处理,变化极少)----
COPY requirements-worker.txt /tmp/requirements-worker.txt
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-worker.txt \
&& rm /tmp/requirements-worker.txt
# ---- 依赖分层:业务依赖(变化频繁)----
COPY requirements.txt /tmp/requirements.txt
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
# 复制应用代码
COPY apps/worker/ /app/apps/worker/
COPY apps/api/app/config.py /app/apps/api/app/config.py
@@ -51,13 +107,13 @@ COPY packages/ /app/packages/
COPY alembic.ini /app/alembic.ini
COPY migrations/ /app/migrations/
# 复制 Worker 启动脚本(支持 WORKER_CONCURRENCY 环境变量)
# 复制 Worker 启动脚本
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
# 设置 Python 路径
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONPATH=/app
ENV PYTHONPATH=/app:/app/packages
ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
@@ -55,6 +55,7 @@ class SQLAlchemyEditPlanClipRepository:
duration=clip.duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
status=clip.status,
config=clip.config,
)
@@ -78,6 +79,7 @@ class SQLAlchemyEditPlanClipRepository:
model.duration = clip.duration
model.transition_effect = clip.transition_effect
model.transition_duration = clip.transition_duration
model.playback_speed = clip.playback_speed
model.status = clip.status
model.config = clip.config
model.updated_at = clip.updated_at
@@ -123,6 +125,7 @@ class SQLAlchemyEditPlanClipRepository:
duration=model.duration or 0.0,
transition_effect=model.transition_effect or "cut",
transition_duration=getattr(model, "transition_duration", 0.0) or 0.0,
playback_speed=model.playback_speed or 1.0,
status=EditPlanClipStatus(model.status) if model.status else EditPlanClipStatus.PENDING,
config=model.config or {},
created_at=model.created_at,
@@ -197,6 +197,7 @@ class EditPlanClipModel(Base):
duration = Column(Float, nullable=False, default=0.0)
transition_effect = Column(String(20), nullable=False, default="cut")
transition_duration = Column(Float, nullable=False, default=0.0)
playback_speed = Column(Float, nullable=False, default=1.0)
status = Column(String(20), nullable=False, default="pending", index=True)
config = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+10 -13
View File
@@ -8,8 +8,10 @@ from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from subprocess import CalledProcessError, TimeoutExpired
from packages.shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
logger = logging.getLogger(__name__)
@@ -59,7 +61,7 @@ class AudioMerger:
output_path = os.path.join(temp_dir, f"merged.{output_format}")
cmd = [
"ffmpeg",
FFMPEG_BIN,
"-y",
"-f",
"concat",
@@ -72,21 +74,16 @@ class AudioMerger:
output_path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
logger.error(f"FFmpeg 合并失败: stderr={result.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {result.stderr[:500]}")
try:
run_ffmpeg(cmd, timeout=120)
except CalledProcessError as e:
logger.error(f"FFmpeg 合并失败: stderr={e.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}")
with open(output_path, "rb") as f:
return f.read()
except subprocess.TimeoutExpired:
except TimeoutExpired:
raise AudioMergeError("FFmpeg 合并超时(120 秒)")
except AudioMergeError:
raise
@@ -15,6 +15,7 @@ import httpx
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.tts_job.text_splitter import split_text
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
logger = logging.getLogger(__name__)
@@ -224,10 +225,13 @@ class TTSStreamingService:
# ── 工具方法 ────────────────────────────────────────────
def _download_audio(self, url: str) -> bytes:
"""下载音频数据。"""
resp = httpx.get(url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
return resp.content
"""下载音频数据(含 SSRF 防护 + 大小限制 + 重定向校验)"""
return safe_download_bytes(
url,
purpose="tts_streaming_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
async def _stream_audio_chunks(self, websocket: Any, audio_data: bytes) -> int:
"""将音频数据分块通过 WebSocket 推送。
+23 -15
View File
@@ -19,16 +19,19 @@ from typing import Optional
import httpx
from packages.application.cosyvoice_service import (
CosyVoiceAuthError,
CosyVoiceError,
CosyVoiceService,
)
from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError, CosyVoiceService
from packages.application.tts_job.audio_merger import AudioMerger
from packages.application.tts_job.text_splitter import split_text
from packages.domain.tts_job import TTSJob, TTSJobStatus
from packages.ports.tts_job_repository import TTSJobRepository
from packages.shared.storage import SharedStorageService, get_shared_storage_service
from packages.shared.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
UrlSecurityError,
safe_download_bytes,
safe_download_file,
validate_url_safety,
)
logger = logging.getLogger(__name__)
@@ -96,10 +99,13 @@ class TTSWorkflowService:
content_type = content_type_map.get(audio_format, "application/octet-stream")
try:
# 下载临时音频
resp = httpx.get(temp_url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
audio_data = resp.content
# 安全下载临时音频(SSRF 防护 + 大小限制 + 重定向校验)
audio_data = safe_download_bytes(
temp_url,
purpose="tts_audio_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
# 上传到 OSS
file_obj = io.BytesIO(audio_data)
@@ -463,13 +469,15 @@ class TTSWorkflowService:
total_duration += result.get("duration", 0.0)
# 下载分段音频到临时文件
resp = httpx.get(audio_url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
# 安全下载分段音频到临时文件SSRF 防护 + 大小限制)
seg_path = os.path.join(temp_dir, f"seg_{idx:03d}.{job.format}")
with open(seg_path, "wb") as f:
f.write(resp.content)
safe_download_file(
audio_url,
seg_path,
purpose="tts_segment_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
audio_paths.append(seg_path)
# 合并
+10
View File
@@ -51,6 +51,7 @@ class EditPlanClip:
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0 # 0 表示使用全局默认值
playback_speed: float = 1.0 # 0 或 1.0 表示原速,范围 0.25~4.0
status: EditPlanClipStatus = EditPlanClipStatus.PENDING
config: dict[str, Any] = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -70,6 +71,7 @@ class EditPlanClip:
duration: float = 0.0,
transition_effect: str = "cut",
transition_duration: float = 0.0,
playback_speed: float = 1.0,
config: dict[str, Any] | None = None,
) -> EditPlanClip:
"""创建剪辑计划片段"""
@@ -81,6 +83,13 @@ class EditPlanClip:
raise ValueError("start_time 不能为负数")
if duration < 0:
raise ValueError("duration 不能为负数")
# 速度边界钳制
if playback_speed <= 0:
playback_speed = 1.0
elif playback_speed < 0.25:
playback_speed = 0.25
elif playback_speed > 4.0:
playback_speed = 4.0
return cls(
id=uuid4().hex,
@@ -94,6 +103,7 @@ class EditPlanClip:
duration=duration,
transition_effect=transition_effect.strip() or "cut",
transition_duration=max(0.0, transition_duration),
playback_speed=playback_speed,
status=EditPlanClipStatus.PENDING,
config=config or {},
)
+79
View File
@@ -0,0 +1,79 @@
"""FFmpeg 共享工具 — packages/shared 层.
仅包含与业务无关的底层原语FFmpeg/FFprobe 二进制路径run_ffmpeg 执行器
业务相关的滤镜构建视频探测等留在 apps/worker/video_processing/ffmpeg_utils.py
application 层和 worker 层都可以引用本模块避免跨层依赖
"""
from __future__ import annotations
import logging
import shutil
import subprocess # nosec B404
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致进程永久阻塞
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
DEFAULT_FFMPEG_TIMEOUT = 1800
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
def run_ffmpeg(
command: list[str],
*,
capture_output: bool = True,
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
) -> tuple[str, str]:
"""执行 FFmpeg 命令(统一入口)。
Args:
command: 完整的 ffmpeg 命令列表 "ffmpeg" 本身
capture_output: 是否捕获 stdout/stderr
timeout: 超时时间默认 1800s30分钟None 表示不设超时不推荐
Returns:
(stdout, stderr) 元组
Raises:
subprocess.CalledProcessError: 命令执行失败时抛出
异常信息包含完整 stderr 以便排查
subprocess.TimeoutExpired: 超时未完成时抛出FFmpeg 进程会被 kill
"""
try:
result = subprocess.run( # nosec B603
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=True,
timeout=timeout,
)
return (result.stdout or "", result.stderr or "")
except subprocess.TimeoutExpired:
logger.error(
"FFmpeg 命令超时 (%ds): command=%s",
timeout or -1,
" ".join(str(c) for c in command[:20]),
)
raise
except subprocess.CalledProcessError as e:
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
stderr_text = (e.stderr or "").strip()
logger.error(
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
e.returncode,
" ".join(str(c) for c in command[:20]), # 截断过长的命令
stderr_text[:5000], # 截断过长的 stderr
)
raise
+542
View File
@@ -0,0 +1,542 @@
"""URL 安全校验工具 — SSRF 防护.
统一的外部 URL 安全校验方案覆盖所有渲染管线和 TTS 中的外部下载场景
放在 packages/shared/ 作为单一来源worker application 层都可引用
防护要点
1. Scheme 白名单仅允许 http/https
2. 主机 SSRF 防护禁止内网 IP回环地址链路本地地址元数据服务
3. 端口白名单仅允许 80/443标准 HTTP/HTTPS
4. 域名校验禁止 IP 直接访问除非在白名单中
5. 重定向防护手动跟随重定向每次跳转前重新校验目标 URL
6. 文件大小限制流式下载超过上限立即中断
7. MIME 类型白名单可选的内容类型校验
"""
from __future__ import annotations
import ipaddress
import logging
import os
import socket
import urllib.error
import urllib.request
from urllib.parse import urljoin, urlparse
logger = logging.getLogger(__name__)
# 允许的 URL scheme
ALLOWED_SCHEMES = {"http", "https"}
# 允许的端口(标准 HTTP/HTTPS
ALLOWED_PORTS = {80, 443}
# 可信域名白名单(可根据实际 OSS/CDN 域名配置)
# 从环境变量读取,格式:"oss-cn-hangzhou.aliyuncs.com,cdn.example.com"
# 默认空表示所有公网域名都允许,但仍会做 SSRF 检查
TRUSTED_DOMAINS: set[str] = set()
_env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "")
if _env_trusted:
TRUSTED_DOMAINS = {d.strip() for d in _env_trusted.split(",") if d.strip()}
# 是否允许 IP 直接访问(默认禁止,防止绕过 DNS 校验)
ALLOW_DIRECT_IP = os.environ.get("URL_SECURITY_ALLOW_DIRECT_IP", "false").lower() == "true"
# 最大 URL 长度
MAX_URL_LENGTH = 2048
# 单次下载最大文件大小(默认 200MB)
DEFAULT_MAX_DOWNLOAD_SIZE = int(os.environ.get("URL_SECURITY_MAX_DOWNLOAD_MB", "200")) * 1024 * 1024
# 允许的音频 MIME 类型白名单
ALLOWED_AUDIO_MIME_TYPES = {
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/pcm",
"audio/ogg",
"audio/opus",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"application/octet-stream", # 兼容一些 CDN 返回通用类型
}
# 允许的视频 MIME 类型白名单
ALLOWED_VIDEO_MIME_TYPES = {
"video/mp4",
"video/quicktime",
"video/x-matroska",
"video/webm",
"video/avi",
"video/x-msvideo",
"video/mpeg",
"application/octet-stream",
}
# 允许的图片 MIME 类型白名单
ALLOWED_IMAGE_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
}
# 下载块大小
_DOWNLOAD_CHUNK_SIZE = 8192
# 最大重定向次数
_MAX_REDIRECTS = 5
# 文件魔数(文件头签名)表 — 用于 MIME 白名单校验后的二次真实性校验
# key: MIME 类型,value: 签名列表,任一签名匹配即通过
# 每条签名: list of (offset, bytes),所有条目都匹配才算该签名命中(支持多处联合匹配如 RIFF+WAVE)
_MAGIC_NUMBERS: dict[str, list[list[tuple[int, bytes]]]] = {
# ── 音频 ──
"audio/mpeg": [
[(0, b"ID3")], # ID3v2 标签
[(0, b"\xff\xfb")], # MPEG1 Layer3
[(0, b"\xff\xf3")], # MPEG2 Layer3
[(0, b"\xff\xf2")], # MPEG2.5 Layer3
[(0, b"\xff\xfa")], # MPEG1 Layer2
[(0, b"\xff\xf9")], # 其他 MPEG ADTS
],
"audio/wav": [
[(0, b"RIFF"), (8, b"WAVE")], # RIFF + WAVE
],
"audio/x-wav": [
[(0, b"RIFF"), (8, b"WAVE")],
],
"audio/ogg": [
[(0, b"OggS")],
],
"application/ogg": [
[(0, b"OggS")],
],
"audio/flac": [
[(0, b"fLaC")],
],
"audio/aac": [
[(0, b"\xff\xf1")], # ADTS MPEG-4
[(0, b"\xff\xf9")], # ADTS MPEG-2
],
"audio/aacp": [
[(0, b"\xff\xf1")],
[(0, b"\xff\xf9")],
],
"audio/mp4": [
[(4, b"ftyp")], # ISO Base Media (M4A)
],
"audio/x-m4a": [
[(4, b"ftyp")],
],
# ── 视频 ──
"video/mp4": [
[(4, b"ftyp")], # ISO Base Media (MP4)
],
"video/quicktime": [
[(4, b"ftyp")],
],
"video/x-matroska": [
[(0, b"\x1a\x45\xdf\xa3")], # EBML header
],
"video/webm": [
[(0, b"\x1a\x45\xdf\xa3")],
],
"video/x-msvideo": [
[(0, b"RIFF"), (8, b"AVI ")],
],
# ── 图片 ──
"image/jpeg": [
[(0, b"\xff\xd8\xff")],
],
"image/png": [
[(0, b"\x89PNG\r\n\x1a\n")],
],
"image/gif": [
[(0, b"GIF87a")],
[(0, b"GIF89a")],
],
"image/webp": [
[(0, b"RIFF"), (8, b"WEBP")],
],
"image/bmp": [
[(0, b"BM")],
],
}
# 魔数校验最大读取字节数(文件头)
_MAGIC_CHECK_READ_SIZE = 256
def _validate_magic_number(file_path: str, allowed_mime_types: set[str]) -> None:
"""校验文件头魔数是否与允许的 MIME 类型匹配.
读取文件前 256 字节 allowed_mime_types 对应格式的魔数逐一比对
任一类型匹配即通过全部不匹配则抛出 UrlSecurityError
仅当 allowed_mime_types 非空时执行空文件视为不匹配
Args:
file_path: 本地文件路径
allowed_mime_types: 允许的 MIME 类型集合
Raises:
UrlSecurityError: 文件魔数与所有允许类型均不匹配
"""
# 收集所有允许类型对应的魔数签名
signatures: list[list[tuple[int, bytes]]] = []
for mime in allowed_mime_types:
sigs = _MAGIC_NUMBERS.get(mime)
if sigs:
signatures.extend(sigs)
# 如果没有已知魔数(比如自定义 MIME),跳过校验不阻断
if not signatures:
return
try:
with open(file_path, "rb") as f:
header = f.read(_MAGIC_CHECK_READ_SIZE)
except OSError as e:
raise UrlSecurityError(f"读取文件头失败: {e}") from e
if not header:
raise UrlSecurityError("文件为空,无法校验格式")
# 任一签名匹配即通过
for sig in signatures:
match = True
for offset, expected in sig:
if offset + len(expected) > len(header):
match = False
break
if header[offset : offset + len(expected)] != expected:
match = False
break
if match:
return
raise UrlSecurityError(
f"文件魔数与允许的 MIME 类型不匹配,"
f"允许类型: {sorted(allowed_mime_types)}"
f"文件头前16字节: {header[:16].hex()}"
)
class UrlSecurityError(ValueError):
"""URL 安全校验失败."""
pass
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""禁止自动重定向的 handler,用于手动控制重定向以做安全校验."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: N802
return None
def validate_url_safety(url: str, *, purpose: str = "download") -> str:
"""校验 URL 安全性,返回标准化后的 URL(供下游使用).
Args:
url: 待校验的 URL
purpose: 用途描述用于日志 "bgm_download""tts_download"
Returns:
标准化后的 URL
Raises:
UrlSecurityError: URL 不安全
"""
if not url:
raise UrlSecurityError("URL 为空")
if len(url) > MAX_URL_LENGTH:
raise UrlSecurityError(f"URL 过长 ({len(url)} > {MAX_URL_LENGTH})")
# 解析 URL
try:
parsed = urlparse(url)
except Exception as e:
raise UrlSecurityError(f"URL 解析失败: {e}") from e
# 1. Scheme 校验
if not parsed.scheme or parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise UrlSecurityError(f"不允许的 URL scheme: {parsed.scheme}")
# 2. 主机名校验
hostname = parsed.hostname
if not hostname:
raise UrlSecurityError("URL 缺少主机名")
# 2.1 常见内网主机名前置拦截(防止 DNS rebinding 绕过)
_check_internal_hostnames(hostname)
# 3. 端口校验
port = parsed.port
if port is not None and port not in ALLOWED_PORTS:
raise UrlSecurityError(f"不允许的端口: {port}")
# 4. SSRF 防护 - 解析 IP 并检查
try:
# 先判断是否是 IP 地址
ip_obj = None
try:
ip_obj = ipaddress.ip_address(hostname)
except ValueError:
pass # 不是 IP,继续走域名解析
if ip_obj is not None:
# 是直接 IP 访问
if not ALLOW_DIRECT_IP and not _is_trusted_ip(ip_obj):
raise UrlSecurityError(f"禁止直接 IP 访问: {hostname}")
_check_ssrf_ip(ip_obj)
else:
# 域名 — 解析 DNS 检查 SSRF
_check_ssrf_domain(hostname)
except UrlSecurityError:
raise
except Exception as e:
logger.warning("URL 安全校验异常: url=%s purpose=%s error=%s", url[:80], purpose, e)
raise UrlSecurityError(f"URL 安全校验异常: {e}") from e
# 5. 可信域名校验(如果配置了白名单)
if TRUSTED_DOMAINS and not _is_trusted_domain(hostname):
raise UrlSecurityError(f"域名不在可信白名单中: {hostname}")
logger.debug("URL 安全校验通过: url=%s purpose=%s", url[:80], purpose)
return url
def _check_internal_hostnames(hostname: str) -> None:
"""前置检查常见内网/敏感主机名,防止 DNS 解析层绕过."""
hostname_lower = hostname.lower()
internal_hostnames = {
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata",
"metadata.google.internal",
"169.254.169.254", # 云元数据服务
}
if hostname_lower in internal_hostnames:
raise UrlSecurityError(f"禁止访问内部主机名: {hostname}")
# 检查以 .local / .internal 结尾的主机名
if hostname_lower.endswith((".local", ".internal", ".localdomain")):
raise UrlSecurityError(f"禁止访问内网域名: {hostname}")
def _check_ssrf_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None:
"""检查 IP 是否属于 SSRF 风险范围."""
# 回环地址
if ip_obj.is_loopback:
raise UrlSecurityError(f"禁止访问回环地址: {ip_obj}")
# 私有地址(内网)
if ip_obj.is_private:
raise UrlSecurityError(f"禁止访问内网地址: {ip_obj}")
# 链路本地地址
if ip_obj.is_link_local:
raise UrlSecurityError(f"禁止访问链路本地地址: {ip_obj}")
# 组播地址
if ip_obj.is_multicast:
raise UrlSecurityError(f"禁止访问组播地址: {ip_obj}")
# 未指定地址(0.0.0.0 / ::
if ip_obj.is_unspecified:
raise UrlSecurityError(f"禁止访问未指定地址: {ip_obj}")
# 保留地址
if ip_obj.is_reserved:
raise UrlSecurityError(f"禁止访问保留地址: {ip_obj}")
def _check_ssrf_domain(hostname: str) -> None:
"""对域名做 DNS 解析并检查所有解析结果的 IP 是否安全.
注意这不能完全防止 DNS rebinding但能防御大部分 SSRF 场景
"""
try:
# 解析所有地址
infos = socket.getaddrinfo(hostname, None)
if not infos:
raise UrlSecurityError(f"域名解析失败: {hostname}")
for info in infos:
ip_str = info[4][0]
try:
ip_obj = ipaddress.ip_address(ip_str)
_check_ssrf_ip(ip_obj)
except ValueError:
# 无法解析为 IP,跳过(不应该发生)
continue
except socket.gaierror as e:
raise UrlSecurityError(f"域名解析失败: {hostname} ({e})") from e
def _is_trusted_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""检查 IP 是否在可信列表中(目前通过环境变量配置域名,IP 级信任暂不开放)."""
return False
def _is_trusted_domain(hostname: str) -> bool:
"""检查域名是否在可信白名单中(支持子域名匹配)."""
hostname_lower = hostname.lower()
if hostname_lower in TRUSTED_DOMAINS:
return True
# 检查子域名
for domain in TRUSTED_DOMAINS:
if hostname_lower.endswith("." + domain.lower()):
return True
return False
def is_url_safe(url: str, *, purpose: str = "download") -> bool:
"""便捷函数:检查 URL 是否安全,不抛异常."""
try:
validate_url_safety(url, purpose=purpose)
return True
except UrlSecurityError:
return False
# ── 安全下载 ───────────────────────────────────────────────────────────────────
def safe_download_file(
url: str,
dest_path: str,
*,
purpose: str = "download",
max_size: int = DEFAULT_MAX_DOWNLOAD_SIZE,
allowed_mime_types: set[str] | None = None,
timeout: float = 60.0,
) -> int:
"""安全下载 URL 到本地文件。
包含防护
- SSRF 校验初始 URL + 每次重定向后都校验
- 重定向次数限制 + 手动跟随避免重定向绕过 SSRF
- 文件大小限制流式读取超过立即中断
- MIME 类型白名单可选
- 文件头魔数校验配合 MIME 白名单做二次真实性校验
Args:
url: 下载 URL
dest_path: 目标文件路径
purpose: 用途描述日志用
max_size: 最大下载字节数超过则中断并抛出 UrlSecurityError
allowed_mime_types: 允许的 Content-Type 集合None 表示不校验
timeout: 单次请求超时
Returns:
实际下载的字节数
Raises:
UrlSecurityError: 安全校验失败
"""
current_url = url
redirect_count = 0
total_bytes = 0
# 使用不自动跟随重定向的 opener
no_redirect_opener = urllib.request.build_opener(NoRedirectHandler())
while True:
# 每次请求前都做 SSRF 校验(重定向目标也会校验)
validate_url_safety(current_url, purpose=purpose)
req = urllib.request.Request(current_url, method="GET")
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
try:
resp = no_redirect_opener.open(req, timeout=timeout) # nosec B310
except urllib.error.HTTPError as e:
# 3xx 重定向
if 300 <= e.code < 400 and e.headers.get("Location"):
if redirect_count >= _MAX_REDIRECTS:
raise UrlSecurityError(f"重定向次数超过限制 ({_MAX_REDIRECTS})") from e
redirect_count += 1
current_url = urljoin(current_url, e.headers["Location"])
continue
raise UrlSecurityError(f"HTTP 错误: {e.code} {e.reason}") from e
except urllib.error.URLError as e:
raise UrlSecurityError(f"URL 错误: {e.reason}") from e
try:
# Content-Type 校验
if allowed_mime_types is not None:
content_type = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
if content_type and content_type not in allowed_mime_types:
raise UrlSecurityError(
f"不允许的 Content-Type: {content_type}, " f"允许: {sorted(allowed_mime_types)}"
)
# Content-Length 预检
content_length = resp.headers.get("Content-Length")
if content_length and int(content_length) > max_size:
raise UrlSecurityError(f"文件过大: {content_length} bytes > {max_size} bytes 上限")
# 流式下载,实时检查大小
with open(dest_path, "wb") as f:
while True:
chunk = resp.read(_DOWNLOAD_CHUNK_SIZE)
if not chunk:
break
total_bytes += len(chunk)
if total_bytes > max_size:
raise UrlSecurityError(f"下载超过大小限制: {total_bytes} bytes > {max_size} bytes")
f.write(chunk)
# 文件头魔数校验(MIME 白名单基础上的二次真实性校验)
if allowed_mime_types is not None:
_validate_magic_number(dest_path, allowed_mime_types)
return total_bytes
finally:
resp.close()
def safe_download_bytes(
url: str,
*,
purpose: str = "download",
max_size: int = DEFAULT_MAX_DOWNLOAD_SIZE,
allowed_mime_types: set[str] | None = None,
timeout: float = 60.0,
) -> bytes:
"""安全下载 URL 并返回字节内容。
防护同 safe_download_file但结果返回在内存中适合小文件
"""
import tempfile
fd, tmp_path = tempfile.mkstemp()
os.close(fd)
try:
safe_download_file(
url,
tmp_path,
purpose=purpose,
max_size=max_size,
allowed_mime_types=allowed_mime_types,
timeout=timeout,
)
with open(tmp_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
+49 -18
View File
@@ -48,23 +48,54 @@ omit = [
]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if __name__ == .__main__.:",
"raise NotImplementedError",
"pass",
"if TYPE_CHECKING:",
"class .*Protocol",
"@abstractmethod",
"raise AssertionError",
"raise RuntimeError",
"if 0:",
"if __debug__:",
[tool.ruff]
target-version = "py311"
line-length = 120
exclude = [
".git",
".cache",
"__pycache__",
".venv",
"venv",
"node_modules",
"alembic",
".gitea",
".next",
"dist",
"build",
]
show_missing = true
skip_covered = false
[tool.coverage.xml]
output = "coverage.xml"
[tool.ruff.lint]
# 当前阶段:摸底模式,规则集与原flake8对齐
# 后续迭代计划:
# Phase 1: 修完 bugbear 后正式替换 flake8
# Phase 2: 启用 UP(pyupgrade) + SIM(simplify)
# Phase 3: 启用 RET(return) + ARG(unused-args)
select = [
"E", # pycodestyle errors(同flake8
"F", # pyflakes(同flake8
"W", # pycodestyle warnings(同flake8
"B", # flake8-bugbear(新增,摸底用)
]
# 与原 setup.cfg flake8 配置对齐,确保不新增阻断
ignore = [
"E203",
"E501", # line-too-longblack管)
"E302",
"E402", # module-import-not-at-top(循环导入多)
"E722", # bare-except
"W291",
"W293",
"F401", # unused-import
"F403",
"F405",
"F841", # unused-variable
"B008", # do-not-perform-callback-from-argfastapi依赖注入)
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "F405"]
"tests/*" = ["E402", "F401", "F841"]
"packages/ports/*" = ["E301"]
"apps/*/migrations/*" = ["ALL"]
"alembic/*" = ["ALL"]
Regular → Executable
+1 -1
View File
@@ -1,5 +1,5 @@
[pytest]
pythonpath = . apps/api apps/worker
pythonpath = . apps/api apps/worker packages
testpaths = tests
# ===== 覆盖率配置 =====
Executable → Regular
+1 -1
View File
@@ -13,7 +13,7 @@ uvicorn[standard]==0.32.0
pydantic==2.9.0
# 认证核心
pyjwt==2.9.0
pyjwt==2.13.0
bcrypt==4.2.0
# Redis
+1 -1
View File
@@ -123,7 +123,7 @@ def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
return [REPO_ROOT / f for f in files]
except subprocess.CalledProcessError as e:
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
print(f" 降级为检查所有迁移文件")
print(" 降级为检查所有迁移文件")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))

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